From 1350261230b3132c77b016ca4fe92d5844f06eb1 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 9 Jul 2025 13:06:40 -0400 Subject: [PATCH 001/153] Initial dotnet sdk install CLI definition --- src/Cli/dotnet/CliUsage.cs | 1 + .../Sdk/Install/SdkInstallCommandParser.cs | 64 +++++++++++++++++++ .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 + src/Cli/dotnet/Parser.cs | 2 + 4 files changed, 69 insertions(+) create mode 100644 src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index 8e0870a86e84..c8caddcf8142 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -43,6 +43,7 @@ internal static class CliUsage clean {CliCommandStrings.CleanDefinition} format {CliCommandStrings.FormatDefinition} help {CliCommandStrings.HelpDefinition} + install Installs the .NET SDK msbuild {CliCommandStrings.MsBuildDefinition} new {CliCommandStrings.NewDefinition} nuget {CliCommandStrings.NugetDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs new file mode 100644 index 000000000000..6ec1c79cbbc1 --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; + +internal static class SdkInstallCommandParser +{ + + + public static readonly DynamicArgument VersionOrChannelArgument = new("versionOrChannel") + { + HelpName = "VERSION|CHANNEL", + Description = "The version or channel of the .NET SDK to install. For example: latest, 10, 9.0.3xx, 9.0.304", + Arity = ArgumentArity.ZeroOrOne, + }; + + public static readonly Option InstallPathOption = new("--install-path") + { + HelpName = "INSTALL_PATH", + Description = "The path to install the .NET SDK to", + }; + + // TODO: Ideally you could just specify --set-default-root, as well as --set-default-root true or --set-default-root false + // This would help for interactivity + public static readonly Option SetDefaultRootOption = new("--set-default-root") + { + Description = "Add installation path to PATH and set DOTNET_ROOT", + Arity = ArgumentArity.Zero + }; + + private static readonly Command SdkInstallCommand = ConstructCommand(); + + public static Command GetSdkInstallCommand() + { + return SdkInstallCommand; + } + + // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: + // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // So we create a separate instance for each case + private static readonly Command RootInstallCommand = ConstructCommand(); + + public static Command GetRootInstallCommand() + { + return RootInstallCommand; + } + + private static Command ConstructCommand() + { + Command command = new("install", "Installs the .NET SDK"); + + command.Arguments.Add(VersionOrChannelArgument); + + command.Options.Add(InstallPathOption); + command.Options.Add(SetDefaultRootOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 061eed3dbdf2..d01a8128b5f1 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,6 +5,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; +using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -24,6 +25,7 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 4bddb07f976e..8a99a184ff3e 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,6 +35,7 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; +using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -87,6 +88,7 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), + SdkInstallCommandParser.GetRootInstallCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() From 413e698c47e0176426fb5bada1b13a9c35108e1a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 13 Jul 2025 22:14:08 -0400 Subject: [PATCH 002/153] Add SDK update command --- src/Cli/dotnet/CliUsage.cs | 1 + .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 + .../Sdk/Update/SdkUpdateCommandParser.cs | 52 +++++++++++++++++++ src/Cli/dotnet/Parser.cs | 2 + 4 files changed, 57 insertions(+) create mode 100644 src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index c8caddcf8142..50b77bf0351f 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -58,6 +58,7 @@ install Installs the .NET SDK store {CliCommandStrings.StoreDefinition} test {CliCommandStrings.TestDefinition} tool {CliCommandStrings.ToolDefinition} + update Updates the .NET SDK vstest {CliCommandStrings.VsTestDefinition} workload {CliCommandStrings.WorkloadDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index d01a8128b5f1..80d665052003 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -6,6 +6,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; using Microsoft.DotNet.Cli.Commands.Sdk.Install; +using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -26,6 +27,7 @@ private static Command ConstructCommand() DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs new file mode 100644 index 000000000000..6dc0198a522a --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Update; + +internal static class SdkUpdateCommandParser +{ + + public static readonly Option UpdateAllOption = new("--all") + { + Description = "Update all installed SDKs", + Arity = ArgumentArity.Zero + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in global.json files to the updated SDK version", + Arity = ArgumentArity.Zero + }; + + private static readonly Command SdkUpdateCommand = ConstructCommand(); + + public static Command GetSdkUpdateCommand() + { + return SdkUpdateCommand; + } + + // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes the following exception: + // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // So we create a separate instance for each case + private static readonly Command RootUpdateCommand = ConstructCommand(); + + public static Command GetRootUpdateCommand() + { + return RootUpdateCommand; + } + + private static Command ConstructCommand() + { + Command command = new("update", "Updates the .NET SDK"); + + command.Options.Add(UpdateAllOption); + command.Options.Add(UpdateGlobalJsonOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 8a99a184ff3e..d7a24ffdab71 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -36,6 +36,7 @@ using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; using Microsoft.DotNet.Cli.Commands.Sdk.Install; +using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -89,6 +90,7 @@ public static class Parser HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), SdkInstallCommandParser.GetRootInstallCommand(), + SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() From 61e48aaed1740b0bf7f8766eecb0bbd3f1145ceb Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 13 Jul 2025 22:27:57 -0400 Subject: [PATCH 003/153] Rename VersionOrChannel argument to just Channel --- .../Commands/Sdk/Install/SdkInstallCommandParser.cs | 12 ++++++++---- .../Commands/Sdk/Update/SdkUpdateCommandParser.cs | 7 +++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs index 6ec1c79cbbc1..697f924a6f99 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -10,10 +10,10 @@ internal static class SdkInstallCommandParser { - public static readonly DynamicArgument VersionOrChannelArgument = new("versionOrChannel") + public static readonly DynamicArgument ChannelArgument = new("channel") { - HelpName = "VERSION|CHANNEL", - Description = "The version or channel of the .NET SDK to install. For example: latest, 10, 9.0.3xx, 9.0.304", + HelpName = "CHANNEL", + Description = "The channel of the .NET SDK to install. For example: latest, 10, or 9.0.3xx. A specific version (for example 9.0.304) can also be specified.", Arity = ArgumentArity.ZeroOrOne, }; @@ -31,6 +31,8 @@ internal static class SdkInstallCommandParser Arity = ArgumentArity.Zero }; + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + private static readonly Command SdkInstallCommand = ConstructCommand(); public static Command GetSdkInstallCommand() @@ -57,7 +59,9 @@ private static Command ConstructCommand() command.Options.Add(InstallPathOption); command.Options.Add(SetDefaultRootOption); - command.SetAction(parseResult => 0); + command.Options.Add(InteractiveOption); + + command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); return command; } diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs index 6dc0198a522a..a32e4db68a8f 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -21,6 +21,8 @@ internal static class SdkUpdateCommandParser Arity = ArgumentArity.Zero }; + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + private static readonly Command SdkUpdateCommand = ConstructCommand(); public static Command GetSdkUpdateCommand() @@ -28,8 +30,7 @@ public static Command GetSdkUpdateCommand() return SdkUpdateCommand; } - // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes the following exception: - // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes an InvalidOperationException // So we create a separate instance for each case private static readonly Command RootUpdateCommand = ConstructCommand(); @@ -45,6 +46,8 @@ private static Command ConstructCommand() command.Options.Add(UpdateAllOption); command.Options.Add(UpdateGlobalJsonOption); + command.Options.Add(InteractiveOption); + command.SetAction(parseResult => 0); return command; From 51ffe3dd01648b43b025246900ac246dc85c9682 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sat, 19 Jul 2025 20:36:59 -0400 Subject: [PATCH 004/153] Implement part of UI for install command --- Directory.Packages.props | 1 + .../Commands/Sdk/Install/SdkInstallCommand.cs | 197 ++++++++++++++++++ .../Sdk/Install/SdkInstallCommandParser.cs | 23 +- .../Sdk/Update/SdkUpdateCommandParser.cs | 2 +- src/Cli/dotnet/dotnet.csproj | 1 + 5 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ae06797fdda4..5c8e2c7e23c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -104,6 +104,7 @@ + diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs new file mode 100644 index 000000000000..b1d9b32f67aa --- /dev/null +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; + +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; + +internal class SdkInstallCommand(ParseResult result) : CommandBase(result) +{ + private readonly string? _versionOrChannel = result.GetValue(SdkInstallCommandParser.ChannelArgument); + private readonly string? _installPath = result.GetValue(SdkInstallCommandParser.InstallPathOption); + private readonly bool? _setDefaultInstall = result.GetValue(SdkInstallCommandParser.SetDefaultInstallOption); + private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); + private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); + + + + public override int Execute() + { + //bool? updateGlobalJson = null; + + //var updateGlobalJsonOption = _parseResult.GetResult(SdkInstallCommandParser.UpdateGlobalJsonOption)!; + //if (updateGlobalJsonOption.Implicit) + //{ + + //} + + //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); + + string? globalJsonPath = FindGlobalJson(); + + string? currentUserInstallPath; + DefaultInstall defaultInstallState = GetDefaultInstallState(out currentUserInstallPath); + + string? resolvedInstallPath = null; + + if (globalJsonPath != null) + { + string? installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + + if (installPathFromGlobalJson != null && _installPath != null && + // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? + !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) + { + // TODO: Add parameter to override error + Reporter.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + return 1; + } + + resolvedInstallPath = installPathFromGlobalJson; + } + + if (resolvedInstallPath == null) + { + resolvedInstallPath = _installPath; + } + + if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) + { + // If a user installation is already set up, we don't need to prompt for the install path + resolvedInstallPath = currentUserInstallPath; + } + + if (resolvedInstallPath == null) + { + if (_interactive) + { + resolvedInstallPath = SpectreAnsiConsole.Prompt( + new TextPrompt("Where should we install the .NET SDK to?)") + .DefaultValue(GetDefaultInstallPath())); + } + else + { + // If no install path is specified, use the default install path + resolvedInstallPath = GetDefaultInstallPath(); + } + } + + string? channelFromGlobalJson = null; + if (globalJsonPath != null) + { + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonPath); + } + + bool? resolvedUpdateGlobalJson = null; + + if (channelFromGlobalJson != null && _versionOrChannel != null && + // TODO: Should channel comparison be case-sensitive? + !channelFromGlobalJson.Equals(_versionOrChannel, StringComparison.OrdinalIgnoreCase)) + { + if (_interactive && _updateGlobalJson == null) + { + resolvedUpdateGlobalJson = SpectreAnsiConsole.Confirm( + $"The channel specified in global.json ({channelFromGlobalJson}) does not match the channel specified ({_versionOrChannel}). Do you want to update global.json to match the specified channel?", + defaultValue: true); + } + } + + string? resolvedChannel = null; + + if (channelFromGlobalJson != null) + { + resolvedChannel = channelFromGlobalJson; + } + else if (_versionOrChannel != null) + { + resolvedChannel = _versionOrChannel; + } + else + { + if (_interactive) + { + + Console.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + Console.WriteLine("You can also specify a specific version (for example 9.0.304)."); + + resolvedChannel = SpectreAnsiConsole.Prompt( + new TextPrompt("Which channel of the .NET SDK do you want to install?") + .DefaultValue("latest")); + } + else + { + resolvedChannel = "latest"; // Default to latest if no channel is specified + } + } + + bool? resolvedSetDefaultInstall = _setDefaultInstall; + + if (resolvedSetDefaultInstall == null) + { + if (_interactive) + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else + { + resolvedSetDefaultInstall = false; // Default to not setting the default install path if not specified + } + } + + + + + Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + + return 0; + } + + + string? FindGlobalJson() + { + //return null; + return @"d:\git\dotnet-sdk\global.json"; + } + + string? ResolveInstallPathFromGlobalJson(string globalJsonPath) + { + return null; + } + + string? ResolveChannelFromGlobalJson(string globalJsonPath) + { + //return null; + return "9.0"; + } + + string GetDefaultInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + List GetAvailableChannels() + { + return ["latest", "preview", "10", "10.0.1xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + + enum DefaultInstall + { + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User + } + + DefaultInstall GetDefaultInstallState(out string? userInstallPath) + { + userInstallPath = null; + return DefaultInstall.None; + } +} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs index 697f924a6f99..b8d3b542c837 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -23,12 +23,18 @@ internal static class SdkInstallCommandParser Description = "The path to install the .NET SDK to", }; - // TODO: Ideally you could just specify --set-default-root, as well as --set-default-root true or --set-default-root false - // This would help for interactivity - public static readonly Option SetDefaultRootOption = new("--set-default-root") + public static readonly Option SetDefaultInstallOption = new("--set-default-install") { - Description = "Add installation path to PATH and set DOTNET_ROOT", - Arity = ArgumentArity.Zero + Description = "Set the install path as the default dotnet install. This will update the PATH and DOTNET_ROOT environhment variables.", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in applicable global.json files to the installed SDK version", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null }; public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); @@ -41,7 +47,7 @@ public static Command GetSdkInstallCommand() } // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: - // System.InvalidOperationException: Command install has more than one child named "versionOrChannel". + // System.InvalidOperationException: Command install has more than one child named "channel". // So we create a separate instance for each case private static readonly Command RootInstallCommand = ConstructCommand(); @@ -54,10 +60,11 @@ private static Command ConstructCommand() { Command command = new("install", "Installs the .NET SDK"); - command.Arguments.Add(VersionOrChannelArgument); + command.Arguments.Add(ChannelArgument); command.Options.Add(InstallPathOption); - command.Options.Add(SetDefaultRootOption); + command.Options.Add(SetDefaultInstallOption); + command.Options.Add(UpdateGlobalJsonOption); command.Options.Add(InteractiveOption); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs index a32e4db68a8f..6cd55766d9c9 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -17,7 +17,7 @@ internal static class SdkUpdateCommandParser public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") { - Description = "Update the sdk version in global.json files to the updated SDK version", + Description = "Update the sdk version in applicable global.json files to the updated SDK version", Arity = ArgumentArity.Zero }; diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index 156dc1027688..f133ad234ca9 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -61,6 +61,7 @@ + From 5834280e189877cfc06e612221832fb938cf14c8 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 14:50:33 -0400 Subject: [PATCH 005/153] Add more installer UI --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 102 +++++++++++++++--- 1 file changed, 90 insertions(+), 12 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index b1d9b32f67aa..6a205e714712 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -33,14 +33,15 @@ public override int Execute() string? globalJsonPath = FindGlobalJson(); - string? currentUserInstallPath; - DefaultInstall defaultInstallState = GetDefaultInstallState(out currentUserInstallPath); + string? currentInstallPath; + DefaultInstall defaultInstallState = GetDefaultInstallState(out currentInstallPath); string? resolvedInstallPath = null; + string? installPathFromGlobalJson = null; if (globalJsonPath != null) { - string? installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); if (installPathFromGlobalJson != null && _installPath != null && // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? @@ -62,7 +63,7 @@ public override int Execute() if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) { // If a user installation is already set up, we don't need to prompt for the install path - resolvedInstallPath = currentUserInstallPath; + resolvedInstallPath = currentInstallPath; } if (resolvedInstallPath == null) @@ -104,6 +105,8 @@ public override int Execute() if (channelFromGlobalJson != null) { + Console.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + resolvedChannel = channelFromGlobalJson; } else if (_versionOrChannel != null) @@ -132,11 +135,43 @@ public override int Execute() if (resolvedSetDefaultInstall == null) { - if (_interactive) + // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) + if (_interactive && installPathFromGlobalJson == null) { - resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", - defaultValue: true); + if (defaultInstallState == DefaultInstall.None) + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else if (defaultInstallState == DefaultInstall.User) + { + // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive + if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) + { + // No need to prompt here, the default install is already set up. + } + else + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"The default dotnet install is currently set to {currentInstallPath}. Do you want to change it to {resolvedInstallPath}?", + defaultValue: false); + } + } + else if (defaultInstallState == DefaultInstall.Admin) + { + Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + + "in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); + Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else if (defaultInstallState == DefaultInstall.Inconsistent) + { + // TODO: Figure out what to do here + resolvedSetDefaultInstall = false; + } } else { @@ -149,6 +184,43 @@ public override int Execute() Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + + // Download the file to a temp path with progress + string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(downloadLink)); + using (var httpClient = new System.Net.Http.HttpClient()) + { + SpectreAnsiConsole.Progress() + .Start(ctx => + { + var task = ctx.AddTask($"Downloading {Path.GetFileName(downloadLink)}"); + using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + { + response.EnsureSuccessStatusCode(); + var contentLength = response.Content.Headers.ContentLength ?? 0; + using (var stream = response.Content.ReadAsStream()) + using (var fileStream = File.Create(tempFilePath)) + { + var buffer = new byte[81920]; + long totalRead = 0; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + fileStream.Write(buffer, 0, read); + totalRead += read; + if (contentLength > 0) + { + task.Value = (double)totalRead / contentLength * 100; + } + } + task.Value = 100; + } + } + }); + } + Console.WriteLine($"Downloaded to: {tempFilePath}"); + + return 0; } @@ -161,13 +233,14 @@ public override int Execute() string? ResolveInstallPathFromGlobalJson(string globalJsonPath) { - return null; + return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); } string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; - return "9.0"; + //return "9.0"; + return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } string GetDefaultInstallPath() @@ -189,9 +262,14 @@ enum DefaultInstall User } - DefaultInstall GetDefaultInstallState(out string? userInstallPath) + DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - userInstallPath = null; + currentInstallPath = null; return DefaultInstall.None; } + + bool IsElevated() + { + return false; + } } From 1bf3e57bc29ccae1b0a1e0bfc4141918fbecc0fd Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 17:47:50 -0400 Subject: [PATCH 006/153] UI improvements and add dnup shims for demo --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 21 +++++++++++-------- src/Layout/redist/dnup | 6 ++++++ src/Layout/redist/dnup.cmd | 6 ++++++ .../targets/GenerateInstallerLayout.targets | 2 ++ 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 src/Layout/redist/dnup create mode 100644 src/Layout/redist/dnup.cmd diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index 6a205e714712..9c1423fbf58e 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -161,7 +161,7 @@ public override int Execute() else if (defaultInstallState == DefaultInstall.Admin) { Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + - "in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", @@ -179,10 +179,7 @@ public override int Execute() } } - - - - Console.WriteLine($"Installing .NET SDK '{resolvedChannel}' to '{resolvedInstallPath}'..."); + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannel}[/] to [blue]{resolvedInstallPath}[/]..."); string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; @@ -193,7 +190,7 @@ public override int Execute() SpectreAnsiConsole.Progress() .Start(ctx => { - var task = ctx.AddTask($"Downloading {Path.GetFileName(downloadLink)}"); + var task = ctx.AddTask($"Downloading"); using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) { response.EnsureSuccessStatusCode(); @@ -218,7 +215,7 @@ public override int Execute() } }); } - Console.WriteLine($"Downloaded to: {tempFilePath}"); + Console.WriteLine($"Complete!"); return 0; @@ -264,8 +261,14 @@ enum DefaultInstall DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - currentInstallPath = null; - return DefaultInstall.None; + var testHookDefaultInstall = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + DefaultInstall returnValue = DefaultInstall.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = DefaultInstall.None; + } + currentInstallPath = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; } bool IsElevated() diff --git a/src/Layout/redist/dnup b/src/Layout/redist/dnup new file mode 100644 index 000000000000..04758bf9f8d4 --- /dev/null +++ b/src/Layout/redist/dnup @@ -0,0 +1,6 @@ +#!/bin/sh +if [ $# -eq 0 ]; then + "$(dirname "$0")/dotnet" install +else + "$(dirname "$0")/dotnet" "$@" +fi diff --git a/src/Layout/redist/dnup.cmd b/src/Layout/redist/dnup.cmd new file mode 100644 index 000000000000..7d8cb5bc7af0 --- /dev/null +++ b/src/Layout/redist/dnup.cmd @@ -0,0 +1,6 @@ +@echo off +if "%~1"=="" ( + "%~dp0dotnet.exe" install +) else ( + "%~dp0dotnet.exe" %* +) diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 4c7aa7749e3b..14adead05b41 100644 --- a/src/Layout/redist/targets/GenerateInstallerLayout.targets +++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets @@ -68,7 +68,9 @@ + + From 225a5cf3c25e9b6f9b52c9c86e8c468c44233ee8 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 21 Jul 2025 18:59:29 -0400 Subject: [PATCH 007/153] More install experience updates --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 163 ++++++++++++++---- 1 file changed, 132 insertions(+), 31 deletions(-) diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs index 9c1423fbf58e..607b40ac9e35 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Net.Http; +using Microsoft.Deployment.DotNet.Releases; using Microsoft.DotNet.Cli.Utils; using Spectre.Console; + using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; @@ -105,7 +108,7 @@ public override int Execute() if (channelFromGlobalJson != null) { - Console.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } @@ -118,8 +121,8 @@ public override int Execute() if (_interactive) { - Console.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); - Console.WriteLine("You can also specify a specific version (for example 9.0.304)."); + SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + SpectreAnsiConsole.WriteLine("You can also specify a specific version (for example 9.0.304)."); resolvedChannel = SpectreAnsiConsole.Prompt( new TextPrompt("Which channel of the .NET SDK do you want to install?") @@ -141,7 +144,7 @@ public override int Execute() if (defaultInstallState == DefaultInstall.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } else if (defaultInstallState == DefaultInstall.User) @@ -160,11 +163,11 @@ public override int Execute() } else if (defaultInstallState == DefaultInstall.Admin) { - Console.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + + SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); - Console.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - "Do you want to set this install path as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } else if (defaultInstallState == DefaultInstall.Inconsistent) @@ -179,48 +182,105 @@ public override int Execute() } } - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannel}[/] to [blue]{resolvedInstallPath}[/]..."); + List additionalVersionsToInstall = new(); + + var resolvedChannelVersion = ResolveChannelVersion(resolvedChannel); + + if (resolvedSetDefaultInstall == true && defaultInstallState == DefaultInstall.Admin) + { + if (_interactive) + { + var latestAdminVersion = GetLatestInstalledAdminVersion(); + if (new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) + { + SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); + + if (SpectreAnsiConsole.Confirm($"Also install .NET SDK {latestAdminVersion}?", + defaultValue: true)) + { + additionalVersionsToInstall.Add(latestAdminVersion); + } + } + } + else + { + // TODO: Add command-linen option for installing admin versions locally + } + } + + + + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; // Download the file to a temp path with progress - string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(downloadLink)); using (var httpClient = new System.Net.Http.HttpClient()) { SpectreAnsiConsole.Progress() .Start(ctx => { - var task = ctx.AddTask($"Downloading"); - using (var response = httpClient.GetAsync(downloadLink, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + var task = ctx.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); + + List additionalDownloads = additionalVersionsToInstall.Select(version => { - response.EnsureSuccessStatusCode(); - var contentLength = response.Content.Headers.ContentLength ?? 0; - using (var stream = response.Content.ReadAsStream()) - using (var fileStream = File.Create(tempFilePath)) + var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); + return (Action) (() => { - var buffer = new byte[81920]; - long totalRead = 0; - int read; - while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - { - fileStream.Write(buffer, 0, read); - totalRead += read; - if (contentLength > 0) - { - task.Value = (double)totalRead / contentLength * 100; - } - } - task.Value = 100; - } + Download(downloadLink, httpClient, additionalTask); + }); + }).ToList(); + + Download(downloadLink, httpClient, task); + + + foreach (var additionalDownload in additionalDownloads) + { + additionalDownload(); } }); } - Console.WriteLine($"Complete!"); + SpectreAnsiConsole.WriteLine($"Complete!"); return 0; } + void Download(string url, HttpClient httpClient, ProgressTask task) + { + //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); + //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + //{ + // response.EnsureSuccessStatusCode(); + // var contentLength = response.Content.Headers.ContentLength ?? 0; + // using (var stream = response.Content.ReadAsStream()) + // using (var fileStream = File.Create(tempFilePath)) + // { + // var buffer = new byte[81920]; + // long totalRead = 0; + // int read; + // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + // { + // fileStream.Write(buffer, 0, read); + // totalRead += read; + // if (contentLength > 0) + // { + // task.Value = (double)totalRead / contentLength * 100; + // } + // } + // task.Value = 100; + // } + //} + + for (int i=0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + string? FindGlobalJson() { @@ -247,7 +307,38 @@ string GetDefaultInstallPath() List GetAvailableChannels() { - return ["latest", "preview", "10", "10.0.1xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + + string ResolveChannelVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.203"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } + + return channel; + } enum DefaultInstall @@ -271,6 +362,16 @@ DefaultInstall GetDefaultInstallState(out string? currentInstallPath) return returnValue; } + string GetLatestInstalledAdminVersion() + { + var latestAdminVersion = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.203"; + } + return latestAdminVersion; + } + bool IsElevated() { return false; From 9f6340c8417b78e7081920035db01fe1e5c52df4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 15 Aug 2025 09:46:54 -0400 Subject: [PATCH 008/153] Add SdkInstallCommand code to dnup --- sdk.slnx | 4 ++- .../dotnet/Commands/Sdk/SdkCommandParser.cs | 4 +-- src/Cli/dotnet/Parser.cs | 4 +-- src/Installer/dnup/CommandBase.cs | 29 +++++++++++++++++++ .../Commands/Sdk/Install/SdkInstallCommand.cs | 15 +++++----- .../Sdk/Install/SdkInstallCommandParser.cs | 7 ++--- src/Installer/dnup/CommonOptions.cs | 23 +++++++++++++++ src/Installer/dnup/Program.cs | 2 ++ src/Installer/dnup/dnup.csproj | 26 +++++++++++++++++ 9 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 src/Installer/dnup/CommandBase.cs rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Install/SdkInstallCommand.cs (94%) rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Install/SdkInstallCommandParser.cs (92%) create mode 100644 src/Installer/dnup/CommonOptions.cs create mode 100644 src/Installer/dnup/Program.cs create mode 100644 src/Installer/dnup/dnup.csproj diff --git a/sdk.slnx b/sdk.slnx index a2a04af45101..4726827cd086 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -3,7 +3,6 @@ - @@ -86,6 +85,9 @@ + + + diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 80d665052003..9a5ba2627eb7 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,7 +5,7 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; -using Microsoft.DotNet.Cli.Commands.Sdk.Install; +//using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; @@ -26,7 +26,7 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); - command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index d7a24ffdab71..8fcc522bcf30 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,7 +35,7 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; -using Microsoft.DotNet.Cli.Commands.Sdk.Install; +//using Microsoft.DotNet.Cli.Commands.Sdk.Install; using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; @@ -89,7 +89,7 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), - SdkInstallCommandParser.GetRootInstallCommand(), + //SdkInstallCommandParser.GetRootInstallCommand(), SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), diff --git a/src/Installer/dnup/CommandBase.cs b/src/Installer/dnup/CommandBase.cs new file mode 100644 index 000000000000..e1b27c8efc9f --- /dev/null +++ b/src/Installer/dnup/CommandBase.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public abstract class CommandBase +{ + protected ParseResult _parseResult; + + protected CommandBase(ParseResult parseResult) + { + _parseResult = parseResult; + //ShowHelpOrErrorIfAppropriate(parseResult); + } + + //protected CommandBase() { } + + //protected virtual void ShowHelpOrErrorIfAppropriate(ParseResult parseResult) + //{ + // parseResult.ShowHelpOrErrorIfAppropriate(); + //} + + public abstract int Execute(); +} diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs similarity index 94% rename from src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs rename to src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 607b40ac9e35..2a73b140bf4f 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -4,13 +4,12 @@ using System.CommandLine; using System.Net.Http; using Microsoft.Deployment.DotNet.Releases; -using Microsoft.DotNet.Cli.Utils; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; internal class SdkInstallCommand(ParseResult result) : CommandBase(result) { @@ -51,7 +50,7 @@ public override int Execute() !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) { // TODO: Add parameter to override error - Reporter.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); return 1; } @@ -290,14 +289,14 @@ void Download(string url, HttpClient httpClient, ProgressTask task) string? ResolveInstallPathFromGlobalJson(string globalJsonPath) { - return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); } string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; //return "9.0"; - return Env.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } string GetDefaultInstallPath() @@ -352,19 +351,19 @@ enum DefaultInstall DefaultInstall GetDefaultInstallState(out string? currentInstallPath) { - var testHookDefaultInstall = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); DefaultInstall returnValue = DefaultInstall.None; if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) { returnValue = DefaultInstall.None; } - currentInstallPath = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); return returnValue; } string GetLatestInstalledAdminVersion() { - var latestAdminVersion = Env.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); if (string.IsNullOrEmpty(latestAdminVersion)) { latestAdminVersion = "10.0.203"; diff --git a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs similarity index 92% rename from src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs rename to src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index b8d3b542c837..7d4418fa0ec9 100644 --- a/src/Cli/dotnet/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -2,15 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Install; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; internal static class SdkInstallCommandParser { - public static readonly DynamicArgument ChannelArgument = new("channel") + public static readonly Argument ChannelArgument = new("channel") { HelpName = "CHANNEL", Description = "The channel of the .NET SDK to install. For example: latest, 10, or 9.0.3xx. A specific version (for example 9.0.304) can also be specified.", @@ -37,7 +36,7 @@ internal static class SdkInstallCommandParser DefaultValueFactory = r => null }; - public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; private static readonly Command SdkInstallCommand = ConstructCommand(); diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs new file mode 100644 index 000000000000..659acc433e47 --- /dev/null +++ b/src/Installer/dnup/CommonOptions.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class CommonOptions +{ + public static Option InteractiveOption = new("--interactive") + { + Description = "Allows the command to stop and wait for user input or action (for example to complete authentication).", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() + }; + + + private static bool IsCIEnvironmentOrRedirected() => + new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs new file mode 100644 index 000000000000..3751555cbd32 --- /dev/null +++ b/src/Installer/dnup/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj new file mode 100644 index 000000000000..a8a91ba4f464 --- /dev/null +++ b/src/Installer/dnup/dnup.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + true + + + + Microsoft.DotNet.Tools.Bootstrapper + + + + + + + + + + + + + + From eaffa9457585b4408ee6fc4fa95e990052e4cf1e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 15 Aug 2025 10:29:24 -0400 Subject: [PATCH 009/153] Wire up sdk install command for dnup --- .../dotnet/Commands/Sdk/SdkCommandParser.cs | 2 +- .../dnup/Commands/Sdk/SdkCommandParser.cs | 33 +++++++++++++++ src/Installer/dnup/Parser.cs | 41 +++++++++++++++++++ src/Installer/dnup/Program.cs | 8 +++- src/Installer/dnup/dnup.csproj | 3 ++ 5 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs create mode 100644 src/Installer/dnup/Parser.cs diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index 9a5ba2627eb7..e030768e14ab 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -29,7 +29,7 @@ private static Command ConstructCommand() //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); - command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); return command; } diff --git a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs new file mode 100644 index 000000000000..eb525222fd02 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk +{ + internal class SdkCommandParser + { + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + Command command = new("sdk"); + //command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + //command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + + return command; + } + } +} diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs new file mode 100644 index 000000000000..286a8a7d255d --- /dev/null +++ b/src/Installer/dnup/Parser.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Completions; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class Parser + { + public static ParserConfiguration ParserConfiguration { get; } = new() + { + EnablePosixBundling = false, + //ResponseFileTokenReplacer = TokenPerLine + }; + + public static InvocationConfiguration InvocationConfiguration { get; } = new() + { + //EnableDefaultExceptionHandler = false, + }; + + public static ParseResult Parse(string[] args) => RootCommand.Parse(args, ParserConfiguration); + public static int Invoke(ParseResult parseResult) => parseResult.Invoke(InvocationConfiguration); + + private static RootCommand RootCommand { get; } = ConfigureCommandLine(new() + { + Directives = { new DiagramDirective(), new SuggestDirective(), new EnvironmentVariablesDirective() } + }); + + private static RootCommand ConfigureCommandLine(RootCommand rootCommand) + { + rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); + + return rootCommand; + } + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index 3751555cbd32..e0c8b17b1476 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -1,2 +1,6 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); + +using Microsoft.DotNet.Tools.Bootstrapper; + +var parseResult = Parser.Parse(args); + +return Parser.Invoke(parseResult); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index a8a91ba4f464..e0e183a715de 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -6,6 +6,9 @@ enable enable true + + + $(NoWarn);CS8002 From 4394b54c4c926724936d8a5ca8aaf287cbd0d74f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 18 Aug 2025 20:18:11 -0400 Subject: [PATCH 010/153] Add string resources to dnup project --- src/Installer/dnup/CommonOptions.cs | 2 +- src/Installer/dnup/Strings.resx | 123 +++++++++++++++++++++ src/Installer/dnup/dnup.csproj | 4 + src/Installer/dnup/xlf/Strings.cs.xlf | 12 ++ src/Installer/dnup/xlf/Strings.de.xlf | 12 ++ src/Installer/dnup/xlf/Strings.es.xlf | 12 ++ src/Installer/dnup/xlf/Strings.fr.xlf | 12 ++ src/Installer/dnup/xlf/Strings.it.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ja.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ko.xlf | 12 ++ src/Installer/dnup/xlf/Strings.pl.xlf | 12 ++ src/Installer/dnup/xlf/Strings.pt-BR.xlf | 12 ++ src/Installer/dnup/xlf/Strings.ru.xlf | 12 ++ src/Installer/dnup/xlf/Strings.tr.xlf | 12 ++ src/Installer/dnup/xlf/Strings.zh-Hans.xlf | 12 ++ src/Installer/dnup/xlf/Strings.zh-Hant.xlf | 12 ++ 16 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/Installer/dnup/Strings.resx create mode 100644 src/Installer/dnup/xlf/Strings.cs.xlf create mode 100644 src/Installer/dnup/xlf/Strings.de.xlf create mode 100644 src/Installer/dnup/xlf/Strings.es.xlf create mode 100644 src/Installer/dnup/xlf/Strings.fr.xlf create mode 100644 src/Installer/dnup/xlf/Strings.it.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ja.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ko.xlf create mode 100644 src/Installer/dnup/xlf/Strings.pl.xlf create mode 100644 src/Installer/dnup/xlf/Strings.pt-BR.xlf create mode 100644 src/Installer/dnup/xlf/Strings.ru.xlf create mode 100644 src/Installer/dnup/xlf/Strings.tr.xlf create mode 100644 src/Installer/dnup/xlf/Strings.zh-Hans.xlf create mode 100644 src/Installer/dnup/xlf/Strings.zh-Hant.xlf diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs index 659acc433e47..c643593ed8d6 100644 --- a/src/Installer/dnup/CommonOptions.cs +++ b/src/Installer/dnup/CommonOptions.cs @@ -12,7 +12,7 @@ internal class CommonOptions { public static Option InteractiveOption = new("--interactive") { - Description = "Allows the command to stop and wait for user input or action (for example to complete authentication).", + Description = Strings.CommandInteractiveOptionDescription, Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() }; diff --git a/src/Installer/dnup/Strings.resx b/src/Installer/dnup/Strings.resx new file mode 100644 index 000000000000..b522f258c0d5 --- /dev/null +++ b/src/Installer/dnup/Strings.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Allows the command to stop and wait for user input or action. + + \ No newline at end of file diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index e0e183a715de..8408f8dfefcc 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -25,5 +25,9 @@ + + + + diff --git a/src/Installer/dnup/xlf/Strings.cs.xlf b/src/Installer/dnup/xlf/Strings.cs.xlf new file mode 100644 index 000000000000..583703c00e49 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.cs.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.de.xlf b/src/Installer/dnup/xlf/Strings.de.xlf new file mode 100644 index 000000000000..02601d0c046b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.de.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.es.xlf b/src/Installer/dnup/xlf/Strings.es.xlf new file mode 100644 index 000000000000..4e14bffe08d7 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.es.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.fr.xlf b/src/Installer/dnup/xlf/Strings.fr.xlf new file mode 100644 index 000000000000..c34156a2faa2 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.fr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.it.xlf b/src/Installer/dnup/xlf/Strings.it.xlf new file mode 100644 index 000000000000..056f4ac60a30 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.it.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ja.xlf b/src/Installer/dnup/xlf/Strings.ja.xlf new file mode 100644 index 000000000000..d6a0e83e1bf1 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ja.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ko.xlf b/src/Installer/dnup/xlf/Strings.ko.xlf new file mode 100644 index 000000000000..02e4bfaa7562 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ko.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pl.xlf b/src/Installer/dnup/xlf/Strings.pl.xlf new file mode 100644 index 000000000000..b5f83b4e62e9 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pl.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pt-BR.xlf b/src/Installer/dnup/xlf/Strings.pt-BR.xlf new file mode 100644 index 000000000000..e3f001a9a86b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pt-BR.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ru.xlf b/src/Installer/dnup/xlf/Strings.ru.xlf new file mode 100644 index 000000000000..2b09b5339f71 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ru.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.tr.xlf b/src/Installer/dnup/xlf/Strings.tr.xlf new file mode 100644 index 000000000000..50a5749de51b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.tr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hans.xlf b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf new file mode 100644 index 000000000000..95c76c2608e3 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hant.xlf b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf new file mode 100644 index 000000000000..6780ad69c7e8 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file From 37c42aa6052996e3d57700d1f1c5fa02b9c4ce72 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 19 Aug 2025 21:21:23 -0400 Subject: [PATCH 011/153] Add initial installer interfaces for dnup --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 184 +++++++++--------- src/Installer/dnup/IDotnetInstaller.cs | 49 +++++ src/Installer/dnup/dnup.csproj | 4 +- 3 files changed, 144 insertions(+), 93 deletions(-) create mode 100644 src/Installer/dnup/IDotnetInstaller.cs diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 2a73b140bf4f..35b46922c827 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -19,7 +19,8 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - + private readonly IDotnetInstaller _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() { @@ -33,17 +34,17 @@ public override int Execute() //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); - string? globalJsonPath = FindGlobalJson(); + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - DefaultInstall defaultInstallState = GetDefaultInstallState(out currentInstallPath); + SdkInstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; string? installPathFromGlobalJson = null; - if (globalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath != null) { - installPathFromGlobalJson = ResolveInstallPathFromGlobalJson(globalJsonPath); + installPathFromGlobalJson = globalJsonInfo.SdkPath; if (installPathFromGlobalJson != null && _installPath != null && // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? @@ -62,7 +63,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && defaultInstallState == DefaultInstall.User) + if (resolvedInstallPath == null && defaultInstallState == SdkInstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentInstallPath; @@ -74,19 +75,19 @@ public override int Execute() { resolvedInstallPath = SpectreAnsiConsole.Prompt( new TextPrompt("Where should we install the .NET SDK to?)") - .DefaultValue(GetDefaultInstallPath())); + .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); } else { // If no install path is specified, use the default install path - resolvedInstallPath = GetDefaultInstallPath(); + resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); } } string? channelFromGlobalJson = null; - if (globalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath != null) { - channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonPath); + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath); } bool? resolvedUpdateGlobalJson = null; @@ -107,7 +108,7 @@ public override int Execute() if (channelFromGlobalJson != null) { - SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonPath} specifies that version."); + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } @@ -120,7 +121,7 @@ public override int Execute() if (_interactive) { - SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', GetAvailableChannels())); + SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', _releaseInfoProvider.GetAvailableChannels())); SpectreAnsiConsole.WriteLine("You can also specify a specific version (for example 9.0.304)."); resolvedChannel = SpectreAnsiConsole.Prompt( @@ -140,13 +141,13 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (defaultInstallState == DefaultInstall.None) + if (defaultInstallState == SdkInstallType.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == DefaultInstall.User) + else if (defaultInstallState == SdkInstallType.User) { // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) @@ -160,7 +161,7 @@ public override int Execute() defaultValue: false); } } - else if (defaultInstallState == DefaultInstall.Admin) + else if (defaultInstallState == SdkInstallType.Admin) { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); @@ -169,7 +170,7 @@ public override int Execute() $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == DefaultInstall.Inconsistent) + else if (defaultInstallState == SdkInstallType.Inconsistent) { // TODO: Figure out what to do here resolvedSetDefaultInstall = false; @@ -183,14 +184,14 @@ public override int Execute() List additionalVersionsToInstall = new(); - var resolvedChannelVersion = ResolveChannelVersion(resolvedChannel); + var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); - if (resolvedSetDefaultInstall == true && defaultInstallState == DefaultInstall.Admin) + if (resolvedSetDefaultInstall == true && defaultInstallState == SdkInstallType.Admin) { if (_interactive) { - var latestAdminVersion = GetLatestInstalledAdminVersion(); - if (new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) + var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); + if (latestAdminVersion != null && new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) { SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); @@ -208,7 +209,7 @@ public override int Execute() } } - + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); @@ -225,12 +226,12 @@ public override int Execute() List additionalDownloads = additionalVersionsToInstall.Select(version => { var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); - return (Action) (() => + return (Action)(() => { Download(downloadLink, httpClient, additionalTask); }); }).ToList(); - + Download(downloadLink, httpClient, task); @@ -272,7 +273,7 @@ void Download(string url, HttpClient httpClient, ProgressTask task) // } //} - for (int i=0; i < 100; i++) + for (int i = 0; i < 100; i++) { task.Increment(1); Thread.Sleep(20); // Simulate some work @@ -280,18 +281,6 @@ void Download(string url, HttpClient httpClient, ProgressTask task) task.Value = 100; } - - string? FindGlobalJson() - { - //return null; - return @"d:\git\dotnet-sdk\global.json"; - } - - string? ResolveInstallPathFromGlobalJson(string globalJsonPath) - { - return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH"); - } - string? ResolveChannelFromGlobalJson(string globalJsonPath) { //return null; @@ -299,80 +288,93 @@ void Download(string url, HttpClient httpClient, ProgressTask task) return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } - string GetDefaultInstallPath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); - } - - List GetAvailableChannels() + bool IsElevated() { - return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + return false; } - string ResolveChannelVersion(string channel) + class EnvironmentVariableMockDotnetInstaller : IDotnetInstaller { - if (channel == "preview") - { - return "11.0.100-preview.1.42424"; - } - else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) { - return "10.0.203"; - } - else if (channel == "10.0.1xx") - { - return "10.0.106"; - } - else if (channel == "9" || channel == "9.0.3xx") - { - return "9.0.309"; + return new GlobalJsonInfo + { + GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), + SdkVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_VERSION"), + AllowPrerelease = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ALLOW_PRERELEASE"), + RollForward = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ROLLFORWARD"), + SdkPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH") + }; } - else if (channel == "9.0.2xx") + + public string GetDefaultDotnetInstallPath() { - return "9.0.212"; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - else if (channel == "9.0.1xx") + + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) { - return "9.0.115"; + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + SdkInstallType returnValue = SdkInstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = SdkInstallType.None; + } + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; } - return channel; - - } - - enum DefaultInstall - { - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, - Admin, - User - } - DefaultInstall GetDefaultInstallState(out string? currentInstallPath) - { - var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - DefaultInstall returnValue = DefaultInstall.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + public string? GetLatestInstalledAdminVersion() { - returnValue = DefaultInstall.None; + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.203"; + } + return latestAdminVersion; } - currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return returnValue; } - string GetLatestInstalledAdminVersion() + class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider { - var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); - if (string.IsNullOrEmpty(latestAdminVersion)) + public List GetAvailableChannels() { - latestAdminVersion = "10.0.203"; + var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); + if (string.IsNullOrEmpty(channels)) + { + return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; + } + return channels.Split(',').ToList(); } - return latestAdminVersion; - } + public string GetLatestVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.203"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } - bool IsElevated() - { - return false; + return channel; + } } } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs new file mode 100644 index 000000000000..39de21a6d0b3 --- /dev/null +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public interface IDotnetInstaller +{ + GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); + + string GetDefaultDotnetInstallPath(); + + SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); + + string? GetLatestInstalledAdminVersion(); +} + +public enum SdkInstallType +{ + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User +} + +public class GlobalJsonInfo +{ + public string? GlobalJsonPath { get; set; } + + public string? SdkVersion { get; set; } + + public string? AllowPrerelease { get; set; } + + public string? RollForward { get; set; } + + // The sdk.path specified in the global.json, if any + public string? SdkPath { get; set; } + +} + +public interface IReleaseInfoProvider +{ + List GetAvailableChannels(); + string GetLatestVersion(string channel); +} diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index 8408f8dfefcc..e7d769ab218f 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -16,8 +16,8 @@ - - + + From 1641070f5264522649e8d99ef2c3a58ea2c27b07 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 10:43:13 -0400 Subject: [PATCH 012/153] Start implementation of DotnetInstaller Copilot prompt: Implement the GetConfiguredInstallType method in #file:'DotnetInstaler.cs' . This method should look in the PATH and resolve "dotnet" or "dotnet.exe", depending on the current OS. If it is not found, the return value should be SdkInstallType.None. If it is found in Program Files, the install type should be SdkInstallType.Admin. If it is found in another folder, the install type should be SdkInsntallType.User. However, the method should also check the value of DOTNET_ROOT environment variable. For a user install, DOTNET_ROOT should be set to the folder where the dotnet executable is found. For an admin install, DOTNET_ROOT should not be set, but if it is set to the install path under program files, that is OK. If the DOTNET_ROOT value doesn't match the install location, the method should return SdkInstallType.Inconsistent. --- src/Installer/dnup/DotnetInstaler.cs | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Installer/dnup/DotnetInstaler.cs diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaler.cs new file mode 100644 index 000000000000..66d6db98da78 --- /dev/null +++ b/src/Installer/dnup/DotnetInstaler.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class DotnetInstaler : IDotnetInstaller +{ + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + { + currentInstallPath = null; + string? pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(pathEnv)) + { + return SdkInstallType.None; + } + + string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + string[] paths = pathEnv.Split(Path.PathSeparator); + string? foundDotnet = null; + foreach (var dir in paths) + { + try + { + string candidate = Path.Combine(dir.Trim(), exeName); + if (File.Exists(candidate)) + { + foundDotnet = Path.GetFullPath(candidate); + break; + } + } + catch { } + } + + if (foundDotnet == null) + { + return SdkInstallType.None; + } + + string installDir = Path.GetDirectoryName(foundDotnet)!; + currentInstallPath = installDir; + + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) + || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + + if (isAdminInstall) + { + // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir + if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + return SdkInstallType.Inconsistent; + } + return SdkInstallType.Admin; + } + else + { + // User install: DOTNET_ROOT must be set and match installDir + if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) + { + return SdkInstallType.Inconsistent; + } + return SdkInstallType.User; + } + } + + private static bool PathsEqual(string a, string b) + { + return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) => throw new NotImplementedException(); + public string? GetLatestInstalledAdminVersion() + { + // TODO: Implement this + return null; + } +} From 0b39a6e8843c90b7a39cbb3495de07556c1a02b4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 10:58:51 -0400 Subject: [PATCH 013/153] Add dnup solution filter and add Environment provider to project --- dnup.slnf | 8 ++++++++ src/Installer/dnup/dnup.csproj | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 dnup.slnf diff --git a/dnup.slnf b/dnup.slnf new file mode 100644 index 000000000000..f2557db6ee09 --- /dev/null +++ b/dnup.slnf @@ -0,0 +1,8 @@ +{ + "solution": { + "path": "sdk.slnx", + "projects": [ + "src\\Installer\\dnup\\dnup.csproj", + ] + } +} \ No newline at end of file diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index e7d769ab218f..c95bd4902909 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -16,8 +16,11 @@ - - + + + + + From e05b2ad05383062c5a7ce5781cdae606093fbe11 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:03:13 -0400 Subject: [PATCH 014/153] Switch to common implementation of finding dotnet on path Copilot prompt: In #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaler.GetConfiguredInstallType':301-2793 , use #method:'Microsoft.DotNet.Cli.Utils.EnvironmentProvider.GetCommandPath':2399-2843 to search for dotnet on the path. --- src/Installer/dnup/DotnetInstaler.cs | 34 ++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaler.cs index 66d6db98da78..1b4729a9a7bf 100644 --- a/src/Installer/dnup/DotnetInstaler.cs +++ b/src/Installer/dnup/DotnetInstaler.cs @@ -4,38 +4,24 @@ using System; using System.IO; using System.Linq; +using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Tools.Bootstrapper; public class DotnetInstaler : IDotnetInstaller { + private readonly IEnvironmentProvider _environmentProvider; + + public DotnetInstaler(IEnvironmentProvider? environmentProvider = null) + { + _environmentProvider = environmentProvider ?? new EnvironmentProvider(); + } + public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) { currentInstallPath = null; - string? pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(pathEnv)) - { - return SdkInstallType.None; - } - - string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - string[] paths = pathEnv.Split(Path.PathSeparator); - string? foundDotnet = null; - foreach (var dir in paths) - { - try - { - string candidate = Path.Combine(dir.Trim(), exeName); - if (File.Exists(candidate)) - { - foundDotnet = Path.GetFullPath(candidate); - break; - } - } - catch { } - } - - if (foundDotnet == null) + string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); + if (string.IsNullOrEmpty(foundDotnet)) { return SdkInstallType.None; } From c78f19896803b5df0745da394e7a5a860bc4d295 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:10:46 -0400 Subject: [PATCH 015/153] Fix typo --- src/Installer/dnup/{DotnetInstaler.cs => DotnetInstaller.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/Installer/dnup/{DotnetInstaler.cs => DotnetInstaller.cs} (96%) diff --git a/src/Installer/dnup/DotnetInstaler.cs b/src/Installer/dnup/DotnetInstaller.cs similarity index 96% rename from src/Installer/dnup/DotnetInstaler.cs rename to src/Installer/dnup/DotnetInstaller.cs index 1b4729a9a7bf..2c9f55f7b4ed 100644 --- a/src/Installer/dnup/DotnetInstaler.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -8,11 +8,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class DotnetInstaler : IDotnetInstaller +public class DotnetInstaller : IDotnetInstaller { private readonly IEnvironmentProvider _environmentProvider; - public DotnetInstaler(IEnvironmentProvider? environmentProvider = null) + public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) { _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } From e06b134f9f1a48d4cffa98082b871b7a2b229cf2 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:52:41 -0400 Subject: [PATCH 016/153] Add class for global.json contents Copilot prompts: Create a class to represent the contents of a global.json file, and create a corresponding JsonSerializerContext type. Can you refer to https://learn.microsoft.com/en-us/dotnet/core/tools/global-json to update the schema you are using for the global.json file? AllowPrerelease should be a booleann, and instead of Path there should be a Paths property which is an array of strings. --- src/Installer/dnup/GlobalJsonFile.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Installer/dnup/GlobalJsonFile.cs diff --git a/src/Installer/dnup/GlobalJsonFile.cs b/src/Installer/dnup/GlobalJsonFile.cs new file mode 100644 index 000000000000..e22fb8ef92a3 --- /dev/null +++ b/src/Installer/dnup/GlobalJsonFile.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class GlobalJsonFile +{ + public SdkSection? Sdk { get; set; } + + public class SdkSection + { + public string? Version { get; set; } + public bool? AllowPrerelease { get; set; } + public string? RollForward { get; set; } + public string[]? Paths { get; set; } + } +} + +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(GlobalJsonFile))] +public partial class GlobalJsonFileJsonContext : JsonSerializerContext +{ +} From cd575a235b2857019118f6ae34cbb6165233c1ed Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 11:58:05 -0400 Subject: [PATCH 017/153] Rename class and more --- .../dnup/{GlobalJsonFile.cs => GlobalJsonContents.cs} | 6 +++--- src/Installer/dnup/IDotnetInstaller.cs | 9 +-------- 2 files changed, 4 insertions(+), 11 deletions(-) rename src/Installer/dnup/{GlobalJsonFile.cs => GlobalJsonContents.cs} (75%) diff --git a/src/Installer/dnup/GlobalJsonFile.cs b/src/Installer/dnup/GlobalJsonContents.cs similarity index 75% rename from src/Installer/dnup/GlobalJsonFile.cs rename to src/Installer/dnup/GlobalJsonContents.cs index e22fb8ef92a3..d55b7af24c23 100644 --- a/src/Installer/dnup/GlobalJsonFile.cs +++ b/src/Installer/dnup/GlobalJsonContents.cs @@ -2,7 +2,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class GlobalJsonFile +public class GlobalJsonContents { public SdkSection? Sdk { get; set; } @@ -16,7 +16,7 @@ public class SdkSection } [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(GlobalJsonFile))] -public partial class GlobalJsonFileJsonContext : JsonSerializerContext +[JsonSerializable(typeof(GlobalJsonContents))] +public partial class GlobalJsonContentsJsonContext : JsonSerializerContext { } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 39de21a6d0b3..dd2217e2c87a 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -31,14 +31,7 @@ public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } - public string? SdkVersion { get; set; } - - public string? AllowPrerelease { get; set; } - - public string? RollForward { get; set; } - - // The sdk.path specified in the global.json, if any - public string? SdkPath { get; set; } + public GlobalJsonContents? GlobalJsonContents { get; set; } } From 3ac40152813030b55cf961cb7d8c1bcbd696a6c6 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 12:06:21 -0400 Subject: [PATCH 018/153] Implement GetGlobalJsonInfo Copilot prompts: Implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetDefaultDotnetInstallPath':2814-2986 . It look for a global.json file starting in the specified directory and walking up the directory chain. If it finds one, it should deserialize its contents using System.Text.Json APIs and #class:'Microsoft.DotNet.Tools.Bootstrapper.GlobalJsonContentsJsonContext':413-650 , and use that for the return value. Sorry, I meant to ask you to implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':4307-4411 instead of #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetDefaultDotnetInstallPath':2839-2947 . Can you revert your changes and then implement #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':4307-4411 using the instructions I previously provided? --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +--- src/Installer/dnup/DotnetInstaller.cs | 36 ++++++++++++++++++- src/Installer/dnup/IDotnetInstaller.cs | 6 +++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 35b46922c827..680f10c3a70a 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -300,10 +300,7 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) return new GlobalJsonInfo { GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), - SdkVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_VERSION"), - AllowPrerelease = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ALLOW_PRERELEASE"), - RollForward = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_ROLLFORWARD"), - SdkPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_INSTALL_PATH") + GlobalJsonContents = null // Set to null for test mock; update as needed for tests }; } @@ -324,7 +321,6 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) return returnValue; } - public string? GetLatestInstalledAdminVersion() { var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 2c9f55f7b4ed..f57bdf384abd 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Text.Json; using Microsoft.DotNet.Cli.Utils; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -66,7 +67,40 @@ public string GetDefaultDotnetInstallPath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) => throw new NotImplementedException(); + + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + string? directory = initialDirectory; + while (!string.IsNullOrEmpty(directory)) + { + string globalJsonPath = Path.Combine(directory, "global.json"); + if (File.Exists(globalJsonPath)) + { + try + { + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo + { + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; + } + catch + { + // Ignore errors and continue up the directory tree + } + } + var parent = Directory.GetParent(directory); + if (parent == null) + break; + directory = parent.FullName; + } + return new GlobalJsonInfo(); + } + public string? GetLatestInstalledAdminVersion() { // TODO: Implement this diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index dd2217e2c87a..443c9a12666c 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -30,9 +30,13 @@ public enum SdkInstallType public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } - public GlobalJsonContents? GlobalJsonContents { get; set; } + // Convenience properties for compatibility + public string? SdkVersion => GlobalJsonContents?.Sdk?.Version; + public bool? AllowPrerelease => GlobalJsonContents?.Sdk?.AllowPrerelease; + public string? RollForward => GlobalJsonContents?.Sdk?.RollForward; + public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths != null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; } public interface IReleaseInfoProvider From 6fcbe4324144a5f1e816a4815fb97b2568b78526 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 12:13:12 -0400 Subject: [PATCH 019/153] Remove try/catch for loading global.json Copilot prompts: Can you remove the try catch block around reading the global.json file in #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.GetGlobalJsonInfo':3019-4216 ? Also remove the convenience properties you added to GlobalJsonInfo for now. OK, go ahead and add those convencience properties back. --- src/Installer/dnup/DotnetInstaller.cs | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index f57bdf384abd..34b104dba6cc 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -76,22 +76,15 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) string globalJsonPath = Path.Combine(directory, "global.json"); if (File.Exists(globalJsonPath)) { - try + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo { - using var stream = File.OpenRead(globalJsonPath); - var contents = JsonSerializer.Deserialize( - stream, - GlobalJsonContentsJsonContext.Default.GlobalJsonContents); - return new GlobalJsonInfo - { - GlobalJsonPath = globalJsonPath, - GlobalJsonContents = contents - }; - } - catch - { - // Ignore errors and continue up the directory tree - } + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; } var parent = Directory.GetParent(directory); if (parent == null) From bd28700ca011b3cb33fd5e509f31444f571e7349 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 21 Aug 2025 16:46:21 -0400 Subject: [PATCH 020/153] Add more installer methods --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 123 ++++++++++-------- src/Installer/dnup/DotnetInstaller.cs | 5 + src/Installer/dnup/IDotnetInstaller.cs | 9 ++ 3 files changed, 80 insertions(+), 57 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 680f10c3a70a..f81ceee91d42 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -213,73 +213,22 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; - // Download the file to a temp path with progress - using (var httpClient = new System.Net.Http.HttpClient()) - { - SpectreAnsiConsole.Progress() - .Start(ctx => - { - var task = ctx.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); - - List additionalDownloads = additionalVersionsToInstall.Select(version => - { - var additionalTask = ctx.AddTask($"Downloading .NET SDK {version}"); - return (Action)(() => - { - Download(downloadLink, httpClient, additionalTask); - }); - }).ToList(); - Download(downloadLink, httpClient, task); + SpectreAnsiConsole.Progress() + .Start(ctx => + { + _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); + }); - foreach (var additionalDownload in additionalDownloads) - { - additionalDownload(); - } - }); - } SpectreAnsiConsole.WriteLine($"Complete!"); return 0; } - void Download(string url, HttpClient httpClient, ProgressTask task) - { - //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); - //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) - //{ - // response.EnsureSuccessStatusCode(); - // var contentLength = response.Content.Headers.ContentLength ?? 0; - // using (var stream = response.Content.ReadAsStream()) - // using (var fileStream = File.Create(tempFilePath)) - // { - // var buffer = new byte[81920]; - // long totalRead = 0; - // int read; - // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - // { - // fileStream.Write(buffer, 0, read); - // totalRead += read; - // if (contentLength > 0) - // { - // task.Value = (double)totalRead / contentLength * 100; - // } - // } - // task.Value = 100; - // } - //} - - for (int i = 0; i < 100; i++) - { - task.Increment(1); - Thread.Sleep(20); // Simulate some work - } - task.Value = 100; - } + string? ResolveChannelFromGlobalJson(string globalJsonPath) { @@ -330,6 +279,66 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) } return latestAdminVersion; } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + //var task = progressContext.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); + using (var httpClient = new System.Net.Http.HttpClient()) + { + List downloads = sdkVersions.Select(version => + { + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + var task = progressContext.AddTask($"Downloading .NET SDK {version}"); + return (Action)(() => + { + Download(downloadLink, httpClient, task); + }); + }).ToList(); + + + foreach (var download in downloads) + { + download(); + } + } + } + + void Download(string url, HttpClient httpClient, ProgressTask task) + { + //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); + //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) + //{ + // response.EnsureSuccessStatusCode(); + // var contentLength = response.Content.Headers.ContentLength ?? 0; + // using (var stream = response.Content.ReadAsStream()) + // using (var fileStream = File.Create(tempFilePath)) + // { + // var buffer = new byte[81920]; + // long totalRead = 0; + // int read; + // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + // { + // fileStream.Write(buffer, 0, read); + // totalRead += read; + // if (contentLength > 0) + // { + // task.Value = (double)totalRead / contentLength * 100; + // } + // } + // task.Value = 100; + // } + //} + + for (int i = 0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); } class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 34b104dba6cc..120412b1e8ab 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -99,4 +100,8 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) // TODO: Implement this return null; } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); } diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 443c9a12666c..47affcc51390 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Text; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -16,6 +17,14 @@ public interface IDotnetInstaller SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); string? GetLatestInstalledAdminVersion(); + + void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); + + void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); + + void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null); + + } public enum SdkInstallType From 8e2bdf9b8f963724f28623e8a425a1041bf3368f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 08:50:10 -0400 Subject: [PATCH 021/153] Add calls to new methods --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index f81ceee91d42..590eab9f3f48 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -210,10 +210,11 @@ public override int Execute() } + // TODO: Implement transaction / rollback? + // TODO: Use Mutex to avoid concurrent installs? - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - // Download the file to a temp path with progress + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); SpectreAnsiConsole.Progress() .Start(ctx => @@ -221,6 +222,16 @@ public override int Execute() _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); }); + if (resolvedSetDefaultInstall == true) + { + _dotnetInstaller.ConfigureInstallType(SdkInstallType.User, resolvedInstallPath); + } + + if (resolvedUpdateGlobalJson == true) + { + _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedChannelVersion, globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); + } + SpectreAnsiConsole.WriteLine($"Complete!"); @@ -337,8 +348,14 @@ void Download(string url, HttpClient httpClient, ProgressTask task) task.Value = 100; } - public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) + { + SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); + } + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + { + SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); + } } class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider From 19f42c85c366e15e9133a4f55347d1468d9b0810 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 09:15:53 -0400 Subject: [PATCH 022/153] Initial ConfigureInstallType implementation Copilot prompt: Implement the #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.ConfigureInstallType':4479-4606 method. If the install type is user, remove any other folder with dotnet in it from the PATH and add the dotnetRoot to the PATH. Also set the DOTNET_ROOT environment variable to dotnetRoot. If the install type is Admin, unset DOTNET_ROOT, and add dotnetRoot to the path (removing any other dotnet folder from the path). If the install type is None, unset DOTNET_ROOT and remove any dotnet folder from the path. For any other install type, throw an ArgumentException. --- src/Installer/dnup/DotnetInstaller.cs | 39 ++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 120412b1e8ab..2ce205a030ea 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -103,5 +103,42 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) => throw new NotImplementedException(); + + public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + { + // Get current PATH + var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + // Remove all entries containing "dotnet" (case-insensitive) + pathEntries = pathEntries.Where(p => !p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); + + switch (installType) + { + case SdkInstallType.User: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Set DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + break; + case SdkInstallType.Admin: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Unset DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + break; + case SdkInstallType.None: + // Unset DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + break; + default: + throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); + } + // Update PATH + var newPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User); + } } From a559c31c9c677d5b8498cee12aeaec4a5937e44f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 22 Aug 2025 09:19:22 -0400 Subject: [PATCH 023/153] Fix check for dotnet folder in PATH Copilot prompt: In #method:'Microsoft.DotNet.Tools.Bootstrapper.DotnetInstaller.ConfigureInstallType':4481-6459 what I meant by removing a folder from the path if it has dotnet in it was that you should check the contents of each folder and if it is a dotnet installation folder then it should be removed from the path. A simple way to check if it's a dotnet installation folder is if it has a dotnet executable in it. --- src/Installer/dnup/DotnetInstaller.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 2ce205a030ea..5c4a7ccce83f 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -109,8 +109,9 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Get current PATH var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); - // Remove all entries containing "dotnet" (case-insensitive) - pathEntries = pathEntries.Where(p => !p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); + string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + // Remove only actual dotnet installation folders from PATH + pathEntries = pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName))).ToList(); switch (installType) { From c0075f81265f77cd5f4c332b3f5dbae0d082c6ec Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 25 Aug 2025 14:19:53 -0700 Subject: [PATCH 024/153] Add boilerplate types and interfaces to do installs I renamed the InstallType as the Runtime installs would similarly hold the same properties. --- src/Installer/dnup/DotnetInstall.cs | 38 +++++++++++++++++++++++ src/Installer/dnup/InstallArchitecture.cs | 16 ++++++++++ src/Installer/dnup/InstallMode.cs | 13 ++++++++ src/Installer/dnup/InstallType.cs | 14 +++++++++ 4 files changed, 81 insertions(+) create mode 100644 src/Installer/dnup/DotnetInstall.cs create mode 100644 src/Installer/dnup/InstallArchitecture.cs create mode 100644 src/Installer/dnup/InstallMode.cs create mode 100644 src/Installer/dnup/InstallType.cs diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs new file mode 100644 index 000000000000..46fac7f5a1ec --- /dev/null +++ b/src/Installer/dnup/DotnetInstall.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Base record for .NET installation information with common properties. +/// +internal record DotnetInstallBase( + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) +{ + public Guid Id { get; } = Guid.NewGuid(); +} + +/// +/// Represents a .NET installation with a fully specified version. +/// +internal record DotnetInstall( + string FullySpecifiedVersion, + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + +/// +/// Represents a request for a .NET installation with a channel version. +/// +internal record DotnetInstallRequest( + string ChannelVersion, + string ResolvedDirectory, + InstallType Type, + InstallMode Mode, + InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); diff --git a/src/Installer/dnup/InstallArchitecture.cs b/src/Installer/dnup/InstallArchitecture.cs new file mode 100644 index 000000000000..67dbe11a2156 --- /dev/null +++ b/src/Installer/dnup/InstallArchitecture.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal enum InstallArchitecture + { + x86, + x64, + arm64 + } +} diff --git a/src/Installer/dnup/InstallMode.cs b/src/Installer/dnup/InstallMode.cs new file mode 100644 index 000000000000..14cbfd8e5ab8 --- /dev/null +++ b/src/Installer/dnup/InstallMode.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal enum InstallMode + { + SDK, + Runtime, + ASPNETCore, + WindowsDesktop + } +} diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs new file mode 100644 index 000000000000..065b520e7e6b --- /dev/null +++ b/src/Installer/dnup/InstallType.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public enum InstallType + { + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User + } +} From 060a91b16eb9a68938ee47266108ef37f001f099 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 25 Aug 2025 14:28:53 -0700 Subject: [PATCH 025/153] Add base version class --- src/Installer/dnup/DotnetVersion.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/Installer/dnup/DotnetVersion.cs diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs new file mode 100644 index 000000000000..fa438537d83a --- /dev/null +++ b/src/Installer/dnup/DotnetVersion.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class DotnetVersion + { + } +} From a09f30f927522bd80bb21fc5636905d1bc2ec1fa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 09:25:09 -0700 Subject: [PATCH 026/153] Add dotnetVersion class for version parsing --- src/Installer/dnup/DotnetVersion.cs | 353 +++++++++++++++++++++++++++- 1 file changed, 348 insertions(+), 5 deletions(-) diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index fa438537d83a..c25561ceac16 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -1,13 +1,356 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; +using System.Diagnostics; +using Microsoft.Deployment.DotNet.Releases; -namespace Microsoft.DotNet.Tools.Bootstrapper +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Represents the type of .NET version (SDK or Runtime). +/// +internal enum DotnetVersionType +{ + /// Automatically detect based on version format. + Auto, + /// SDK version (has feature bands, e.g., 8.0.301). + Sdk, + /// Runtime version (no feature bands, e.g., 8.0.7). + Runtime +} + +/// +/// Represents a .NET version string with specialized parsing, comparison, and manipulation capabilities. +/// Acts like a string but provides version-specific operations like feature band extraction and semantic comparisons. +/// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. +/// +[DebuggerDisplay("{Value} ({VersionType})")] +internal readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { - internal class DotnetVersion + private readonly ReleaseVersion? _releaseVersion; + + /// Gets the original version string value. + public string Value { get; } + + /// Gets the version type (SDK or Runtime). + public DotnetVersionType VersionType { get; } + + /// Gets the major version component (e.g., "8" from "8.0.301"). + public int Major => _releaseVersion?.Major ?? 0; + + /// Gets the minor version component (e.g., "0" from "8.0.301"). + public int Minor => _releaseVersion?.Minor ?? 0; + + /// Gets the patch version component (e.g., "301" from "8.0.301"). + public int Patch => _releaseVersion?.Patch ?? 0; + + /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). + public string MajorMinor => $"{Major}.{Minor}"; + + /// Gets whether this version represents a preview version (contains '-preview'). + public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase); + + /// Gets whether this version represents a prerelease (contains '-' but not just build hash). + public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); + + /// Gets whether this is an SDK version (has feature bands). + public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); + + /// Gets whether this is a Runtime version (no feature bands). + public bool IsRuntimeVersion => VersionType == DotnetVersionType.Runtime || + (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Runtime); + + /// Gets whether this version contains a build hash. + public bool HasBuildHash => GetBuildHash() is not null; + + /// Gets whether this version is fully specified (e.g., "8.0.301" vs "8.0" or "8.0.3xx"). + public bool IsFullySpecified => _releaseVersion is not null && + !Value.Contains('x') && + Value.Split('.').Length >= 3; + + /// Gets whether this version uses a non-specific feature band pattern (e.g., "8.0.3xx"). + public bool IsNonSpecificFeatureBand => Value.EndsWith('x') && Value.Split('.').Length == 3; + + /// Gets whether this is just a major or major.minor version (e.g., "8" or "8.0"). + public bool IsNonSpecificMajorMinor => Value.Split('.').Length <= 2 && + Value.Split('.').All(x => int.TryParse(x, out _)); + + /// + /// Initializes a new instance with the specified version string. + /// + /// The version string to parse. + /// The type of version (SDK or Runtime). Auto-detects if not specified. + public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + Value = value ?? string.Empty; + VersionType = versionType; + _releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; + } + + /// + /// Gets the feature band number from the SDK version (e.g., "3" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBand() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 0 ? patchPart[0].ToString() : null; + } + + /// + /// Gets the feature band patch version (e.g., "01" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetFeatureBandPatch() { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length > 1 ? patchPart[1..] : null; } + + /// + /// Gets the complete feature band including patch (e.g., "301" from "8.0.301"). + /// Returns null if this is not an SDK version or doesn't contain a feature band. + /// + public string? GetCompleteBandAndPatch() + { + if (!IsSdkVersion) return null; + + var parts = GetVersionWithoutBuildHash().Split('.'); + if (parts.Length < 3) return null; + + return parts[2].Split('-')[0]; // Remove prerelease suffix if present + } + + /// + /// Gets the prerelease identifier if this is a prerelease version. + /// + public string? GetPrereleaseIdentifier() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[(dashIndex + 1)..] : null; + } + + /// + /// Gets the build hash from the version if present (typically after a '+' or at the end of prerelease). + /// Examples: "8.0.301+abc123" -> "abc123", "8.0.301-preview.1.abc123" -> "abc123" + /// + public string? GetBuildHash() + { + // Build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[(plusIndex + 1)..]; + + // Build hash in prerelease (look for hex-like string at the end) + var prerelease = GetPrereleaseIdentifier(); + if (prerelease is null) return null; + + var parts = prerelease.Split('.'); + var lastPart = parts[^1]; + + // Check if last part looks like a build hash (hex string, 6+ chars) + if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) + return lastPart; + + return null; + } + + /// + /// Gets the version string without any build hash component. + /// + public string GetVersionWithoutBuildHash() + { + var buildHash = GetBuildHash(); + if (buildHash is null) return Value; + + // Remove build hash after '+' + var plusIndex = Value.IndexOf('+'); + if (plusIndex >= 0) + return Value[..plusIndex]; + + // Remove build hash from prerelease + return Value.Replace($".{buildHash}", ""); + } + + /// + /// Detects whether this is an SDK or Runtime version based on the version format. + /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. + /// + private DotnetVersionType DetectVersionType() + { + var parts = GetVersionWithoutBuildHash().Split('.', '-'); + if (parts.Length < 3) return DotnetVersionType.Runtime; + + var patchPart = parts[2]; + + // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) + // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) + if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) + return DotnetVersionType.Sdk; + + return DotnetVersionType.Runtime; + } + + /// + /// Checks if the version only contains a build hash (no other prerelease identifiers). + /// + private bool IsOnlyBuildHash() + { + var dashIndex = Value.IndexOf('-'); + if (dashIndex < 0) return false; + + var afterDash = Value[(dashIndex + 1)..]; + + // Check if what follows the dash is just a build hash + return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); + } + + /// + /// Creates a new version with the specified patch version while preserving other components. + /// + public DotnetVersion WithPatch(int patch) + { + var parts = Value.Split('.'); + if (parts.Length < 3) + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}"); + + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch:D3}{prereleaseAndBuild}"); + } + + /// + /// Creates a new version with the specified feature band while preserving other components. + /// + public DotnetVersion WithFeatureBand(int featureBand) + { + var currentPatch = GetFeatureBandPatch(); + var patch = $"{featureBand}{currentPatch ?? "00"}"; + var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); + return new DotnetVersion($"{Major}.{Minor}.{patch}{prereleaseAndBuild}"); + } + + private string GetPrereleaseAndBuildSuffix() + { + var dashIndex = Value.IndexOf('-'); + return dashIndex >= 0 ? Value[dashIndex..] : string.Empty; + } + + /// + /// Validates that this version string represents a well-formed, fully specified version. + /// + public bool IsValidFullySpecifiedVersion() + { + if (!IsFullySpecified) return false; + + var parts = Value.Split('.', '-')[0].Split('.'); + if (parts.Length < 3 || Value.Length > 20) return false; + + // Check that patch version is reasonable (1-2 digits for feature band, 1-2 for patch) + return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; + } + + #region String-like behavior + + public static implicit operator string(DotnetVersion version) => version.Value; + public static implicit operator DotnetVersion(string version) => new(version); + + /// + /// Creates an SDK version from a string. + /// + public static DotnetVersion FromSdk(string version) => new(version, DotnetVersionType.Sdk); + + /// + /// Creates a Runtime version from a string. + /// + public static DotnetVersion FromRuntime(string version) => new(version, DotnetVersionType.Runtime); + + public override string ToString() => Value; + + public bool Equals(string? other) => string.Equals(Value, other, StringComparison.Ordinal); + + #endregion + + #region IComparable implementations + + public int CompareTo(DotnetVersion other) + { + // Use semantic version comparison if both are valid release versions + if (_releaseVersion is not null && other._releaseVersion is not null) + return _releaseVersion.CompareTo(other._releaseVersion); + + // Fall back to string comparison + return string.Compare(Value, other.Value, StringComparison.Ordinal); + } + + public int CompareTo(string? other) + { + if (other is null) return 1; + return CompareTo(new DotnetVersion(other)); + } + + #endregion + + #region Static utility methods + + /// + /// Determines whether the specified string represents a valid .NET version format. + /// + public static bool IsValidFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return new DotnetVersion(value).IsValidFullySpecifiedVersion() || + new DotnetVersion(value).IsNonSpecificFeatureBand || + new DotnetVersion(value).IsNonSpecificMajorMinor; + } + + /// + /// Tries to parse a version string into a DotnetVersion. + /// + /// The version string to parse. + /// The parsed version if successful. + /// The type of version to parse. Auto-detects if not specified. + public static bool TryParse(string? value, out DotnetVersion version, DotnetVersionType versionType = DotnetVersionType.Auto) + { + version = new DotnetVersion(value, versionType); + return IsValidFormat(value); + } + + /// + /// Parses a version string into a DotnetVersion, throwing on invalid format. + /// + /// The version string to parse. + /// The type of version to parse. Auto-detects if not specified. + public static DotnetVersion Parse(string value, DotnetVersionType versionType = DotnetVersionType.Auto) + { + if (!TryParse(value, out var version, versionType)) + throw new ArgumentException($"'{value}' is not a valid .NET version format.", nameof(value)); + return version; + } + + #endregion + + #region String comparison operators + + public static bool operator <(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) < 0; + public static bool operator <=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) <= 0; + public static bool operator >(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) > 0; + public static bool operator >=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) >= 0; + + public static bool operator ==(DotnetVersion left, string? right) => left.Equals(right); + public static bool operator !=(DotnetVersion left, string? right) => !left.Equals(right); + public static bool operator ==(string? left, DotnetVersion right) => right.Equals(left); + public static bool operator !=(string? left, DotnetVersion right) => !right.Equals(left); + + #endregion } From 3ad43e42095cd25b35166aaa275999f344c80689 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 10:22:14 -0700 Subject: [PATCH 027/153] Add basic tests --- sdk.slnx | 1 + src/Installer/dnup/DotnetVersion.cs | 99 ++++++++++-- src/Installer/dnup/InstallType.cs | 2 +- src/Installer/dnup/Program.cs | 14 +- src/Installer/dnup/dnup.csproj | 6 +- test/dnup.Tests/DotnetInstallTests.cs | 75 +++++++++ test/dnup.Tests/DotnetVersionTests.cs | 212 ++++++++++++++++++++++++++ test/dnup.Tests/ParserTests.cs | 65 ++++++++ test/dnup.Tests/dnup.Tests.csproj | 17 +++ 9 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 test/dnup.Tests/DotnetInstallTests.cs create mode 100644 test/dnup.Tests/DotnetVersionTests.cs create mode 100644 test/dnup.Tests/ParserTests.cs create mode 100644 test/dnup.Tests/dnup.Tests.csproj diff --git a/sdk.slnx b/sdk.slnx index 4726827cd086..2ea3064bed5f 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -294,6 +294,7 @@ + diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index c25561ceac16..b0a89fa7fffb 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -36,10 +36,10 @@ internal enum DotnetVersionType public DotnetVersionType VersionType { get; } /// Gets the major version component (e.g., "8" from "8.0.301"). - public int Major => _releaseVersion?.Major ?? 0; + public int Major => _releaseVersion?.Major ?? ParseMajorDirect(); /// Gets the minor version component (e.g., "0" from "8.0.301"). - public int Minor => _releaseVersion?.Minor ?? 0; + public int Minor => _releaseVersion?.Minor ?? ParseMinorDirect(); /// Gets the patch version component (e.g., "301" from "8.0.301"). public int Patch => _releaseVersion?.Patch ?? 0; @@ -47,14 +47,17 @@ internal enum DotnetVersionType /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). public string MajorMinor => $"{Major}.{Minor}"; - /// Gets whether this version represents a preview version (contains '-preview'). - public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase); + /// Gets whether this version represents a preview version (contains preview, rc, alpha, beta, etc.). + public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-rc", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-alpha", StringComparison.OrdinalIgnoreCase) || + Value.Contains("-beta", StringComparison.OrdinalIgnoreCase); /// Gets whether this version represents a prerelease (contains '-' but not just build hash). public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); /// Gets whether this is an SDK version (has feature bands). - public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || + public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); /// Gets whether this is a Runtime version (no feature bands). @@ -95,11 +98,16 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetFeatureBand() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, feature band is the hundreds digit + // Runtime versions like "8.0.7" should return null, not "7" + if (patchPart.Length < 3) return null; + return patchPart.Length > 0 ? patchPart[0].ToString() : null; } @@ -110,11 +118,15 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetFeatureBandPatch() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, patch is the last two digits + if (patchPart.Length < 3) return null; + return patchPart.Length > 1 ? patchPart[1..] : null; } @@ -125,11 +137,16 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio public string? GetCompleteBandAndPatch() { if (!IsSdkVersion) return null; - + var parts = GetVersionWithoutBuildHash().Split('.'); if (parts.Length < 3) return null; - return parts[2].Split('-')[0]; // Remove prerelease suffix if present + var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix + + // For SDK versions, complete band is 3-digit patch + if (patchPart.Length < 3) return null; + + return patchPart; } /// @@ -158,7 +175,7 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio var parts = prerelease.Split('.'); var lastPart = parts[^1]; - + // Check if last part looks like a build hash (hex string, 6+ chars) if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) return lastPart; @@ -193,12 +210,12 @@ private DotnetVersionType DetectVersionType() if (parts.Length < 3) return DotnetVersionType.Runtime; var patchPart = parts[2]; - + // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) return DotnetVersionType.Sdk; - + return DotnetVersionType.Runtime; } @@ -211,7 +228,7 @@ private bool IsOnlyBuildHash() if (dashIndex < 0) return false; var afterDash = Value[(dashIndex + 1)..]; - + // Check if what follows the dash is just a build hash return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); } @@ -260,6 +277,24 @@ public bool IsValidFullySpecifiedVersion() return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; } + /// + /// Parses major version directly from string for cases where ReleaseVersion parsing fails. + /// + private int ParseMajorDirect() + { + var parts = Value.Split('.'); + return parts.Length > 0 && int.TryParse(parts[0], out var major) ? major : 0; + } + + /// + /// Parses minor version directly from string for cases where ReleaseVersion parsing fails. + /// + private int ParseMinorDirect() + { + var parts = Value.Split('.'); + return parts.Length > 1 && int.TryParse(parts[1], out var minor) ? minor : 0; + } + #region String-like behavior public static implicit operator string(DotnetVersion version) => version.Value; @@ -309,9 +344,41 @@ public int CompareTo(string? other) public static bool IsValidFormat(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; - return new DotnetVersion(value).IsValidFullySpecifiedVersion() || - new DotnetVersion(value).IsNonSpecificFeatureBand || - new DotnetVersion(value).IsNonSpecificMajorMinor; + + var version = new DotnetVersion(value); + + // Valid formats: + // - Fully specified versions: "8.0.301", "7.0.201" + // - Non-specific feature bands: "7.0.2xx" + // - Major.minor versions: "8.0", "7.0" + // - Major only versions: "8", "7" + // - Exclude unreasonable versions like high patch numbers or runtime-like versions with small patch + + if (version.IsFullySpecified) + { + var parts = value.Split('.'); + if (parts.Length >= 3 && int.TryParse(parts[2], out var patch)) + { + // Unreasonably high patch numbers are invalid (e.g., 7.0.1999) + if (patch > 999) return false; + + // Small patch numbers (1-2 digits) are runtime versions and should be valid + // but versions like "7.1.10" are questionable since .NET 7.1 doesn't exist + if (patch < 100 && version.Major <= 8 && version.Minor > 0) return false; + } + return true; + } + + if (version.IsNonSpecificFeatureBand) return true; + + if (version.IsNonSpecificMajorMinor) + { + // Allow reasonable major.minor combinations + // Exclude things like "10.10" which don't make sense for .NET versioning + if (version.Major <= 20 && version.Minor <= 9) return true; + } + + return false; } /// diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs index 065b520e7e6b..688ac1692440 100644 --- a/src/Installer/dnup/InstallType.cs +++ b/src/Installer/dnup/InstallType.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - public enum InstallType + internal enum InstallType { None, // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index e0c8b17b1476..ee656bfa6003 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -1,6 +1,14 @@  using Microsoft.DotNet.Tools.Bootstrapper; -var parseResult = Parser.Parse(args); - -return Parser.Invoke(parseResult); +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class DnupProgram + { + public static int Main(string[] args) + { + var parseResult = Parser.Parse(args); + return Parser.Invoke(parseResult); + } + } +} diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index c95bd4902909..3d343aae531e 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -15,6 +15,10 @@ Microsoft.DotNet.Tools.Bootstrapper + + + + @@ -32,5 +36,5 @@ - + diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs new file mode 100644 index 000000000000..5e4e90812974 --- /dev/null +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class DotnetInstallTests +{ + [Fact] + public void DotnetInstallBase_ShouldInitializeCorrectly() + { + var directory = "/test/directory"; + var type = InstallType.User; + var mode = InstallMode.SDK; + var architecture = InstallArchitecture.x64; + + var install = new DotnetInstallBase(directory, type, mode, architecture); + + install.ResolvedDirectory.Should().Be(directory); + install.Type.Should().Be(type); + install.Mode.Should().Be(mode); + install.Architecture.Should().Be(architecture); + install.Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public void DotnetInstall_ShouldInheritFromBase() + { + var version = "8.0.301"; + var directory = "/test/directory"; + var type = InstallType.User; + var mode = InstallMode.SDK; + var architecture = InstallArchitecture.x64; + + var install = new DotnetInstall(version, directory, type, mode, architecture); + + install.FullySpecifiedVersion.Should().Be(version); + install.ResolvedDirectory.Should().Be(directory); + install.Type.Should().Be(type); + install.Mode.Should().Be(mode); + install.Architecture.Should().Be(architecture); + install.Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public void MultipleInstances_ShouldHaveUniqueIds() + { + // Arrange & Act + var install1 = new DotnetInstallBase("dir1", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install2 = new DotnetInstallBase("dir2", InstallType.Admin, InstallMode.Runtime, InstallArchitecture.x64); + + // Assert + install1.Id.Should().NotBe(install2.Id); + } + + [Fact] + public void Records_ShouldSupportValueEquality() + { + // Arrange + var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + + // Act & Assert + // Records should be equal based on values, except for the Id which is always unique + install1.FullySpecifiedVersion.Should().Be(install2.FullySpecifiedVersion); + install1.ResolvedDirectory.Should().Be(install2.ResolvedDirectory); + install1.Type.Should().Be(install2.Type); + install1.Mode.Should().Be(install2.Mode); + install1.Architecture.Should().Be(install2.Architecture); + + // But Ids should be different + install1.Id.Should().NotBe(install2.Id); + } +} diff --git a/test/dnup.Tests/DotnetVersionTests.cs b/test/dnup.Tests/DotnetVersionTests.cs new file mode 100644 index 000000000000..cf661a8e2366 --- /dev/null +++ b/test/dnup.Tests/DotnetVersionTests.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class DotnetVersionTests +{ + [Theory] + [InlineData("7.0.201", "7")] + [InlineData("7.0.2xx", "7")] + [InlineData("7.1.300", "7")] + [InlineData("10.0.102", "10")] + [InlineData("7", "7")] + [InlineData("7.0", "7")] + public void GetMajor(string version, string expected) => + new DotnetVersion(version).Major.ToString().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "0")] + [InlineData("7.1.300", "1")] + [InlineData("10.0.102", "0")] + [InlineData("7", "0")] + [InlineData("7.0", "0")] + public void GetMinor(string version, string expected) => + new DotnetVersion(version).Minor.ToString().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "7.0")] + [InlineData("7.0.2xx", "7.0")] + [InlineData("7.1.300", "7.1")] + [InlineData("10.0.102", "10.0")] + [InlineData("7", "7.0")] + [InlineData("7.0", "7.0")] + public void GetMajorMinor(string version, string expected) => + new DotnetVersion(version).MajorMinor.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "2")] + [InlineData("7.0.2xx", "2")] + [InlineData("7.1.300", "3")] + [InlineData("10.0.102", "1")] + [InlineData("7.0.221", "2")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetFeatureBand(string version, string? expected) => + DotnetVersion.FromSdk(version).GetFeatureBand().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "01")] + [InlineData("7.1.300", "00")] + [InlineData("10.0.102", "02")] + [InlineData("7.0.221", "21")] + [InlineData("8.0.400-preview.0.24324.5", "00")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetFeatureBandPatch(string version, string? expected) => + DotnetVersion.FromSdk(version).GetFeatureBandPatch().Should().Be(expected); + + [Theory] + [InlineData("7.0.201", "201")] + [InlineData("7.1.300", "300")] + [InlineData("10.0.102", "102")] + [InlineData("7.0.221", "221")] + [InlineData("7.0.7", null)] + [InlineData("8.0", null)] + public void GetCompleteBandAndPatch(string version, string? expected) => + DotnetVersion.FromSdk(version).GetCompleteBandAndPatch().Should().Be(expected); + + [Theory] + [InlineData("7.0", null)] + [InlineData("8.0.10", "10")] + [InlineData("8.0.9-rc.2.24502.A", "9")] + public void GetRuntimePatch(string version, string? expected) + { + var v = DotnetVersion.FromRuntime(version); + var patch = v.Patch == 0 ? null : v.Patch.ToString(); + patch.Should().Be(expected); + } + + [Theory] + [InlineData("8.0.400-preview.0.24324.5", true)] + [InlineData("9.0.0-rc.2", true)] + [InlineData("9.0.0-rc.2.24473.5", true)] + [InlineData("8.0.0-preview.7", true)] + [InlineData("10.0.0-alpha.2.24522.8", true)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", false)] + [InlineData("7.1.10", false)] + [InlineData("7.0.201", false)] + [InlineData("10.0.100-rc.2.25420.109", true)] + public void IsPreview(string version, bool expected) => + new DotnetVersion(version).IsPreview.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", false)] + [InlineData("7.0.2xx", true)] + [InlineData("10.0.102", false)] + public void IsNonSpecificFeatureBand(string version, bool expected) => + new DotnetVersion(version).IsNonSpecificFeatureBand.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", true)] + [InlineData("7.1.300", true)] + [InlineData("10.0.102", true)] + [InlineData("7", false)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", false)] + public void IsFullySpecified(string version, bool expected) => + new DotnetVersion(version).IsFullySpecified.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", false)] + [InlineData("7.1.300", false)] + [InlineData("10.0.102", false)] + [InlineData("7", true)] + [InlineData("7.0.2xx", false)] + [InlineData("7.0", true)] + public void IsNonSpecificMajorMinor(string version, bool expected) => + new DotnetVersion(version).IsNonSpecificMajorMinor.Should().Be(expected); + + [Theory] + [InlineData("7.0.201", true)] + [InlineData("7.1.300", true)] + [InlineData("10.0.102", true)] + [InlineData("7.0.2xx", true)] + [InlineData("7", true)] + [InlineData("7.0", true)] + [InlineData("7.0.1999", false)] + [InlineData("7.1.10", false)] + [InlineData("10.10", false)] + public void IsValidFormat(string version, bool expected) => + DotnetVersion.IsValidFormat(version).Should().Be(expected); + + [Theory] + [InlineData("8.0.301", 0, true, false)] // Auto + [InlineData("8.0.7", 0, false, true)] // Auto + [InlineData("8.0.301", 1, true, false)] // Sdk + [InlineData("8.0.7", 2, false, true)] // Runtime + [InlineData("8.0.7", 1, true, false)] // Sdk + public void VersionTypeDetection(string version, int typeInt, bool isSdk, bool isRuntime) + { + var type = (DotnetVersionType)typeInt; + var v = new DotnetVersion(version, type); + v.IsSdkVersion.Should().Be(isSdk); + v.IsRuntimeVersion.Should().Be(isRuntime); + } + + [Theory] + [InlineData("8.0.301+abc123def456", "abc123def456")] + [InlineData("8.0.301-preview.1.abc123", "abc123")] + [InlineData("8.0.301-abc123def", "abc123def")] + [InlineData("8.0.301", null)] + [InlineData("8.0.301-preview.1", null)] + public void GetBuildHash(string version, string? expected) => + new DotnetVersion(version).GetBuildHash().Should().Be(expected); + + [Theory] + [InlineData("8.0.301+abc123def456", "8.0.301")] + [InlineData("8.0.301-preview.1.abc123", "8.0.301-preview.1")] + [InlineData("8.0.301", "8.0.301")] + public void GetVersionWithoutBuildHash(string version, string expected) => + new DotnetVersion(version).GetVersionWithoutBuildHash().Should().Be(expected); + + [Theory] + [InlineData("8.0.301", "8.0.302", -1)] + [InlineData("8.0.302", "8.0.301", 1)] + [InlineData("8.0.301", "8.0.301", 0)] + public void Comparison(string v1, string v2, int expected) + { + var result = new DotnetVersion(v1).CompareTo(new DotnetVersion(v2)); + if (expected < 0) result.Should().BeNegative(); + else if (expected > 0) result.Should().BePositive(); + else result.Should().Be(0); + } + + [Fact] + public void FactoryMethods() + { + var sdk = DotnetVersion.FromSdk("8.0.7"); + var runtime = DotnetVersion.FromRuntime("8.0.301"); + + sdk.IsSdkVersion.Should().BeTrue(); + sdk.IsRuntimeVersion.Should().BeFalse(); + runtime.IsSdkVersion.Should().BeFalse(); + runtime.IsRuntimeVersion.Should().BeTrue(); + } + + [Fact] + public void ImplicitConversions() + { + DotnetVersion version = "8.0.301"; + string versionString = version; + + version.Value.Should().Be("8.0.301"); + versionString.Should().Be("8.0.301"); + } + + [Fact] + public void TryParse() + { + DotnetVersion.TryParse("8.0.301", out var valid).Should().BeTrue(); + valid.Value.Should().Be("8.0.301"); + + DotnetVersion.TryParse("invalid", out _).Should().BeFalse(); + } + + [Fact] + public void Parse() => + new Action(() => DotnetVersion.Parse("invalid")).Should().Throw(); +} diff --git a/test/dnup.Tests/ParserTests.cs b/test/dnup.Tests/ParserTests.cs new file mode 100644 index 000000000000..20f4958472b0 --- /dev/null +++ b/test/dnup.Tests/ParserTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class ParserTests +{ + [Fact] + public void Parser_ShouldParseValidCommands() + { + // Arrange + var args = new[] { "sdk", "install", "8.0" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleInvalidCommands() + { + // Arrange + var args = new[] { "invalid-command" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().NotBeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleSdkHelp() + { + // Arrange + var args = new[] { "sdk", "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleRootHelp() + { + // Arrange + var args = new[] { "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } +} diff --git a/test/dnup.Tests/dnup.Tests.csproj b/test/dnup.Tests/dnup.Tests.csproj new file mode 100644 index 000000000000..ca702c950709 --- /dev/null +++ b/test/dnup.Tests/dnup.Tests.csproj @@ -0,0 +1,17 @@ + + + + enable + $(ToolsetTargetFramework) + Exe + Microsoft.DotNet.Tools.Bootstrapper + true + Tests\$(MSBuildProjectName) + + + + + + + + From ee2a6c60f48b16788a605e4d6c83205008f6016c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 10:32:10 -0700 Subject: [PATCH 028/153] Replace SDKInstallType with InstallType --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 26 +++++++++---------- src/Installer/dnup/DotnetInstall.cs | 2 +- src/Installer/dnup/DotnetInstaller.cs | 20 +++++++------- src/Installer/dnup/IDotnetInstaller.cs | 13 ++-------- src/Installer/dnup/InstallType.cs | 2 +- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 590eab9f3f48..a448904bdb9e 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -37,7 +37,7 @@ public override int Execute() var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - SdkInstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); + InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; @@ -63,7 +63,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && defaultInstallState == SdkInstallType.User) + if (resolvedInstallPath == null && defaultInstallState == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentInstallPath; @@ -141,13 +141,13 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (defaultInstallState == SdkInstallType.None) + if (defaultInstallState == InstallType.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == SdkInstallType.User) + else if (defaultInstallState == InstallType.User) { // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) @@ -161,7 +161,7 @@ public override int Execute() defaultValue: false); } } - else if (defaultInstallState == SdkInstallType.Admin) + else if (defaultInstallState == InstallType.Admin) { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); @@ -170,7 +170,7 @@ public override int Execute() $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == SdkInstallType.Inconsistent) + else if (defaultInstallState == InstallType.Inconsistent) { // TODO: Figure out what to do here resolvedSetDefaultInstall = false; @@ -186,7 +186,7 @@ public override int Execute() var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); - if (resolvedSetDefaultInstall == true && defaultInstallState == SdkInstallType.Admin) + if (resolvedSetDefaultInstall == true && defaultInstallState == InstallType.Admin) { if (_interactive) { @@ -224,7 +224,7 @@ public override int Execute() if (resolvedSetDefaultInstall == true) { - _dotnetInstaller.ConfigureInstallType(SdkInstallType.User, resolvedInstallPath); + _dotnetInstaller.ConfigureInstallType(InstallType.User, resolvedInstallPath); } if (resolvedUpdateGlobalJson == true) @@ -269,13 +269,13 @@ public string GetDefaultDotnetInstallPath() return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + public InstallType GetConfiguredInstallType(out string? currentInstallPath) { var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - SdkInstallType returnValue = SdkInstallType.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + InstallType returnValue = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) { - returnValue = SdkInstallType.None; + returnValue = InstallType.None; } currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); return returnValue; @@ -352,7 +352,7 @@ public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, b { SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); } - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) { SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 46fac7f5a1ec..a867b4f66fe8 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -28,7 +28,7 @@ internal record DotnetInstall( InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); /// -/// Represents a request for a .NET installation with a channel version. +/// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// internal record DotnetInstallRequest( string ChannelVersion, diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/DotnetInstaller.cs index 5c4a7ccce83f..d345c684747e 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/DotnetInstaller.cs @@ -19,13 +19,13 @@ public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } - public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) + public InstallType GetConfiguredInstallType(out string? currentInstallPath) { currentInstallPath = null; string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); if (string.IsNullOrEmpty(foundDotnet)) { - return SdkInstallType.None; + return InstallType.None; } string installDir = Path.GetDirectoryName(foundDotnet)!; @@ -42,18 +42,18 @@ public SdkInstallType GetConfiguredInstallType(out string? currentInstallPath) // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) { - return SdkInstallType.Inconsistent; + return InstallType.Inconsistent; } - return SdkInstallType.Admin; + return InstallType.Admin; } else { // User install: DOTNET_ROOT must be set and match installDir if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) { - return SdkInstallType.Inconsistent; + return InstallType.Inconsistent; } - return SdkInstallType.User; + return InstallType.User; } } @@ -104,7 +104,7 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); - public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null) + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) { // Get current PATH var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; @@ -115,7 +115,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot switch (installType) { - case SdkInstallType.User: + case InstallType.User: if (string.IsNullOrEmpty(dotnetRoot)) throw new ArgumentNullException(nameof(dotnetRoot)); // Add dotnetRoot to PATH @@ -123,7 +123,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Set DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); break; - case SdkInstallType.Admin: + case InstallType.Admin: if (string.IsNullOrEmpty(dotnetRoot)) throw new ArgumentNullException(nameof(dotnetRoot)); // Add dotnetRoot to PATH @@ -131,7 +131,7 @@ public void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); break; - case SdkInstallType.None: + case InstallType.None: // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); break; diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs index 47affcc51390..b7a2b9c375df 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -14,7 +14,7 @@ public interface IDotnetInstaller string GetDefaultDotnetInstallPath(); - SdkInstallType GetConfiguredInstallType(out string? currentInstallPath); + InstallType GetConfiguredInstallType(out string? currentInstallPath); string? GetLatestInstalledAdminVersion(); @@ -22,20 +22,11 @@ public interface IDotnetInstaller void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); - void ConfigureInstallType(SdkInstallType installType, string? dotnetRoot = null); + void ConfigureInstallType(InstallType installType, string? dotnetRoot = null); } -public enum SdkInstallType -{ - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, - Admin, - User -} - public class GlobalJsonInfo { public string? GlobalJsonPath { get; set; } diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs index 688ac1692440..065b520e7e6b 100644 --- a/src/Installer/dnup/InstallType.cs +++ b/src/Installer/dnup/InstallType.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallType + public enum InstallType { None, // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to From 14a58cbe3ce2f7a65d3904b09e81b55dba6597aa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 11:04:41 -0700 Subject: [PATCH 029/153] Add Base Interface for Install I renamed the DotnetInstaller in an aim to refactor the logic out some more. I think we could have the controller logic that talks to the CLI / UI layer be separate from the class that does the actual install logic to better follow the single responsibility principle and simplify the code into more pieces. --- ...Installer.cs => BootstrapperController.cs} | 4 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 8 +-- src/Installer/dnup/DnupSharedManifest.cs | 23 +++++++ ...nstaller.cs => IBootstrapperController.cs} | 2 +- src/Installer/dnup/IDnupManifest.cs | 11 +++ .../dnup/InstallerOrchestratorSingleton.cs | 69 +++++++++++++++++++ src/Installer/dnup/ScopedMutex.cs | 30 ++++++++ 7 files changed, 140 insertions(+), 7 deletions(-) rename src/Installer/dnup/{DotnetInstaller.cs => BootstrapperController.cs} (97%) create mode 100644 src/Installer/dnup/DnupSharedManifest.cs rename src/Installer/dnup/{IDotnetInstaller.cs => IBootstrapperController.cs} (97%) create mode 100644 src/Installer/dnup/IDnupManifest.cs create mode 100644 src/Installer/dnup/InstallerOrchestratorSingleton.cs create mode 100644 src/Installer/dnup/ScopedMutex.cs diff --git a/src/Installer/dnup/DotnetInstaller.cs b/src/Installer/dnup/BootstrapperController.cs similarity index 97% rename from src/Installer/dnup/DotnetInstaller.cs rename to src/Installer/dnup/BootstrapperController.cs index d345c684747e..ba228e1b17e4 100644 --- a/src/Installer/dnup/DotnetInstaller.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -10,11 +10,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class DotnetInstaller : IDotnetInstaller +public class BootstrapperController : IBootstrapperController { private readonly IEnvironmentProvider _environmentProvider; - public DotnetInstaller(IEnvironmentProvider? environmentProvider = null) + public BootstrapperController(IEnvironmentProvider? environmentProvider = null) { _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index a448904bdb9e..5909cddfe02b 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -19,7 +19,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - private readonly IDotnetInstaller _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IBootstrapperController _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() @@ -37,7 +37,7 @@ public override int Execute() var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; - InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); + InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); string? resolvedInstallPath = null; @@ -239,7 +239,7 @@ public override int Execute() return 0; } - + string? ResolveChannelFromGlobalJson(string globalJsonPath) { @@ -253,7 +253,7 @@ bool IsElevated() return false; } - class EnvironmentVariableMockDotnetInstaller : IDotnetInstaller + class EnvironmentVariableMockDotnetInstaller : IBootstrapperController { public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs new file mode 100644 index 000000000000..9a9ca36d62f0 --- /dev/null +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class DnupSharedManifest : IDnupManifest +{ + public IEnumerable GetInstalledVersions() + { + return []; + } + + public void AddInstalledVersion(DotnetInstall version) + { + } + + public void RemoveInstalledVersion(DotnetInstall version) + { + } +} diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IBootstrapperController.cs similarity index 97% rename from src/Installer/dnup/IDotnetInstaller.cs rename to src/Installer/dnup/IBootstrapperController.cs index b7a2b9c375df..bc4dd5aa7e17 100644 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ b/src/Installer/dnup/IBootstrapperController.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public interface IDotnetInstaller +public interface IBootstrapperController { GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs new file mode 100644 index 000000000000..2439b201f03b --- /dev/null +++ b/src/Installer/dnup/IDnupManifest.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDnupManifest + { + IEnumerable GetInstalledVersions(); + void AddInstalledVersion(DotnetInstall version); + void RemoveInstalledVersion(DotnetInstall version); + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs new file mode 100644 index 000000000000..d33c215148ad --- /dev/null +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class InstallerOrchestratorSingleton +{ + private static readonly InstallerOrchestratorSingleton _instance = new(); + + private InstallerOrchestratorSingleton() + { + } + + public static InstallerOrchestratorSingleton Instance => _instance; + + private ScopedMutex directoryToMutex(string directory) => new ScopedMutex("Global\\" + directory.GetHashCode()); + + private ScopedMutex finalizeLock() => new ScopedMutex("Global\\Finalize"); + + public void Install(DotnetInstallRequest installRequest) + { + // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + + // Grab the mutex on the directory to operate on from installRequest + // Check if the install already exists, if so, return + // If not, release the mutex and begin the installer.prepare + // prepare will download the correct archive to a random user protected folder + // it will then verify the downloaded archive signature / hash. + // + + // Once prepare is over, grab the finalize lock, then grab the directory lock + // Check once again if the install exists, if so, return. + // Run installer.finalize which will extract to the directory to install to. + // validate the install, then write to the dnup shared manifest + // Release + + // Clean up the temp folder + } + + // Add a doc string mentioning you must hold a mutex over the directory + private IEnumerable GetExistingInstalls(string directory) + { + using (var lockScope = directoryToMutex(directory)) + { + if (lockScope.HasHandle) + { + // TODO: Implement logic to get existing installs + return Enumerable.Empty(); + } + return Enumerable.Empty(); + } + } + + private bool InstallAlreadyExists(string directory) + { + using (var lockScope = directoryToMutex(directory)) + { + if (lockScope.HasHandle) + { + // TODO: Implement logic to check if install already exists + return false; + } + return false; + } + } +} diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs new file mode 100644 index 000000000000..de8c1cb202d2 --- /dev/null +++ b/src/Installer/dnup/ScopedMutex.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class ScopedMutex : IDisposable +{ + private readonly Mutex _mutex; + private bool _hasHandle; + + public ScopedMutex(string name) + { + _mutex = new Mutex(false, name); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(10), false); + } + + public bool HasHandle => _hasHandle; + + public void Dispose() + { + if (_hasHandle) + { + _mutex.ReleaseMutex(); + } + _mutex.Dispose(); + } +} From 9c19c6b478b1554bcb986d94e97c1d3f03db0de3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 11:47:35 -0700 Subject: [PATCH 030/153] Add the isolated classes for each responsibility of install --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 31 ++++++++ .../dnup/ArchiveInstallationValidator.cs | 16 ++++ src/Installer/dnup/BootstrapperController.cs | 14 +++- src/Installer/dnup/IChannelVersionResolver.cs | 12 +++ src/Installer/dnup/IDnupManifest.cs | 3 + src/Installer/dnup/IDotnetInstaller.cs | 13 ++++ src/Installer/dnup/IInstallationValidator.cs | 12 +++ .../dnup/InstallerOrchestratorSingleton.cs | 74 ++++++++++--------- .../dnup/ManifestChannelVersionResolver.cs | 22 ++++++ 9 files changed, 162 insertions(+), 35 deletions(-) create mode 100644 src/Installer/dnup/ArchiveDotnetInstaller.cs create mode 100644 src/Installer/dnup/ArchiveInstallationValidator.cs create mode 100644 src/Installer/dnup/IChannelVersionResolver.cs create mode 100644 src/Installer/dnup/IDotnetInstaller.cs create mode 100644 src/Installer/dnup/IInstallationValidator.cs create mode 100644 src/Installer/dnup/ManifestChannelVersionResolver.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs new file mode 100644 index 000000000000..db7d3844339e --- /dev/null +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable +{ + public ArchiveDotnetInstaller(DotnetInstall version) + { + + } + + public void Prepare() + { + // Create a user protected (wrx) random folder in temp + // Download the correct archive to the temp folder + // Verify the hash and or signature of the archive + } + + public void Commit() + { + } + + public void Dispose() + { + // Clean up the temp directory + } +} diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs new file mode 100644 index 000000000000..0a7fee13c49f --- /dev/null +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveInstallationValidator : IInstallationValidator +{ + public bool Validate(DotnetInstall install) + { + // TODO: Implement validation logic + return true; + } +} diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index ba228e1b17e4..a1605cc3d8a1 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -101,7 +101,19 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) return null; } - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + // TODO: Implement proper channel version resolution and parameter mapping + DotnetInstallRequest request = new DotnetInstallRequest( + "TODO_CHANNEL_VERSION", + dotnetRoot, + InstallType.User, + InstallMode.SDK, + InstallArchitecture.x64 + ); + + InstallerOrchestratorSingleton.Instance.Install(request); + } public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) diff --git a/src/Installer/dnup/IChannelVersionResolver.cs b/src/Installer/dnup/IChannelVersionResolver.cs new file mode 100644 index 000000000000..f0cfb323f0f5 --- /dev/null +++ b/src/Installer/dnup/IChannelVersionResolver.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface ChannelVersionResolver + { + public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion); + } +} diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 2439b201f03b..afbc38cd1ccc 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using System.Collections.Generic; namespace Microsoft.DotNet.Tools.Bootstrapper diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs new file mode 100644 index 000000000000..e811c4562e23 --- /dev/null +++ b/src/Installer/dnup/IDotnetInstaller.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDotnetInstaller + { + void Prepare(); + void Commit(); + } +} diff --git a/src/Installer/dnup/IInstallationValidator.cs b/src/Installer/dnup/IInstallationValidator.cs new file mode 100644 index 000000000000..3f9195021160 --- /dev/null +++ b/src/Installer/dnup/IInstallationValidator.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IInstallationValidator + { + bool Validate(DotnetInstall install); + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index d33c215148ad..3aee97c52f42 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -16,54 +16,60 @@ private InstallerOrchestratorSingleton() public static InstallerOrchestratorSingleton Instance => _instance; - private ScopedMutex directoryToMutex(string directory) => new ScopedMutex("Global\\" + directory.GetHashCode()); - - private ScopedMutex finalizeLock() => new ScopedMutex("Global\\Finalize"); + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); public void Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); + + // Check if the install already exists and we don't need to do anything + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + { + return; + } + } - // Grab the mutex on the directory to operate on from installRequest - // Check if the install already exists, if so, return - // If not, release the mutex and begin the installer.prepare - // prepare will download the correct archive to a random user protected folder - // it will then verify the downloaded archive signature / hash. - // + ArchiveDotnetInstaller installer = new ArchiveDotnetInstaller(install); + installer.Prepare(); - // Once prepare is over, grab the finalize lock, then grab the directory lock - // Check once again if the install exists, if so, return. - // Run installer.finalize which will extract to the directory to install to. - // validate the install, then write to the dnup shared manifest - // Release + // Extract and commit the install to the directory + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + { + return; + } + + installer.Commit(); + + ArchiveInstallationValidator validator = new ArchiveInstallationValidator(); + if (validator.Validate(install)) + { + var manifestManager = new DnupSharedManifest(); + manifestManager.AddInstalledVersion(install); + } + else + { + // Handle validation failure + } + } - // Clean up the temp folder + // return exit code or 0 } // Add a doc string mentioning you must hold a mutex over the directory private IEnumerable GetExistingInstalls(string directory) { - using (var lockScope = directoryToMutex(directory)) - { - if (lockScope.HasHandle) - { - // TODO: Implement logic to get existing installs - return Enumerable.Empty(); - } - return Enumerable.Empty(); - } + // assert we have the finalize lock + return Enumerable.Empty(); } - private bool InstallAlreadyExists(string directory) + private bool InstallAlreadyExists(string directory, DotnetInstall install) { - using (var lockScope = directoryToMutex(directory)) - { - if (lockScope.HasHandle) - { - // TODO: Implement logic to check if install already exists - return false; - } - return false; - } + // assert we have the finalize lock + return false; } } diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs new file mode 100644 index 000000000000..b0d0881e1430 --- /dev/null +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ManifestChannelVersionResolver +{ + public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) + { + // TODO: Implement logic to resolve the channel version from the manifest + // For now, return a placeholder + return new DotnetInstall( + "TODO_RESOLVED_VERSION", + dotnetChannelVersion.ResolvedDirectory, + dotnetChannelVersion.Type, + dotnetChannelVersion.Mode, + dotnetChannelVersion.Architecture); + } +} From c97834e60838b5d67003f701cb4c8616bede5c8e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 13:44:08 -0700 Subject: [PATCH 031/153] fill in controller with correct data structure model --- src/Installer/dnup/BootstrapperController.cs | 15 ++++++++++--- src/Installer/dnup/DnupUtilities.cs | 22 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/Installer/dnup/DnupUtilities.cs diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index a1605cc3d8a1..a50c73b6d541 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -103,17 +103,26 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) { - // TODO: Implement proper channel version resolution and parameter mapping + foreach (var channelVersion in sdkVersions) + { + InstallSDK(dotnetRoot, progressContext, channelVersion); + } + } + + private void InstallSDK(string dotnetRoot, ProgressContext progressContext, string channelVersion) + { DotnetInstallRequest request = new DotnetInstallRequest( - "TODO_CHANNEL_VERSION", + channelVersion, dotnetRoot, InstallType.User, InstallMode.SDK, - InstallArchitecture.x64 + // Get current machine architecture and convert it to correct enum value + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture) ); InstallerOrchestratorSingleton.Instance.Install(request); } + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs new file mode 100644 index 000000000000..06d8899efe56 --- /dev/null +++ b/src/Installer/dnup/DnupUtilities.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal static class DnupUtilities +{ + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) + { + return architecture switch + { + System.Runtime.InteropServices.Architecture.X86 => InstallArchitecture.x86, + System.Runtime.InteropServices.Architecture.X64 => InstallArchitecture.x64, + System.Runtime.InteropServices.Architecture.Arm64 => InstallArchitecture.arm64, + _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") + }; + } +} From d3cb10f20832cc1d5e7c3beca22528bb901e6300 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 14:56:18 -0700 Subject: [PATCH 032/153] use some actual version parsing --- src/Installer/dnup/DotnetVersion.cs | 5 +++++ .../dnup/InstallerOrchestratorSingleton.cs | 17 ++++++++-------- .../dnup/ManifestChannelVersionResolver.cs | 20 ++++++++++++++++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index b0a89fa7fffb..e13c3c105fdf 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -200,6 +200,11 @@ public string GetVersionWithoutBuildHash() return Value.Replace($".{buildHash}", ""); } + public bool IsValidMajorVersion() + { + return Major != 0; + } + /// /// Detects whether this is an SDK or Runtime version based on the version format. /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 3aee97c52f42..e323e2f5399c 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -18,7 +18,7 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); - public void Install(DotnetInstallRequest installRequest) + public int Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); @@ -28,11 +28,11 @@ public void Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return; + return 0; } } - ArchiveDotnetInstaller installer = new ArchiveDotnetInstaller(install); + ArchiveDotnetInstaller installer = new(install); installer.Prepare(); // Extract and commit the install to the directory @@ -40,24 +40,25 @@ public void Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return; + return 0; } installer.Commit(); - ArchiveInstallationValidator validator = new ArchiveInstallationValidator(); + ArchiveInstallationValidator validator = new(); if (validator.Validate(install)) { - var manifestManager = new DnupSharedManifest(); + DnupSharedManifest manifestManager = new(); manifestManager.AddInstalledVersion(install); } else { - // Handle validation failure + // TODO Handle validation failure better + return 1; } } - // return exit code or 0 + return 0; } // Add a doc string mentioning you must hold a mutex over the directory diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index b0d0881e1430..da9c3c6fd4bc 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -10,10 +10,24 @@ internal class ManifestChannelVersionResolver { public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) { - // TODO: Implement logic to resolve the channel version from the manifest - // For now, return a placeholder + string fullySpecifiedVersion = dotnetChannelVersion.ChannelVersion; + + DotnetVersion dotnetVersion = new DotnetVersion(fullySpecifiedVersion); + + // Resolve strings or other options + if (!dotnetVersion.IsValidMajorVersion()) + { + // TODO ping the r-manifest to handle 'lts' 'latest' etc + } + + // Make sure the version is fully specified + if (!dotnetVersion.IsFullySpecified) + { + // TODO ping the r-manifest to resolve latest within the specified qualities + } + return new DotnetInstall( - "TODO_RESOLVED_VERSION", + fullySpecifiedVersion, dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, From 69b9821d139915b6f00bd43461a0fb28c90dd13a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 14:59:22 -0700 Subject: [PATCH 033/153] add more context into what to implement for future me or others --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 5 +++++ src/Installer/dnup/ManifestChannelVersionResolver.cs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index db7d3844339e..173ef7e9969a 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -18,10 +18,15 @@ public void Prepare() // Create a user protected (wrx) random folder in temp // Download the correct archive to the temp folder // Verify the hash and or signature of the archive + + // https://github.com/dn-vm/dnvm/blob/e656f6e0011d4d710c94cb520d00604d9058460f/src/dnvm/InstallCommand.cs#L359C47-L359C62 + // Use the MIT license version of basically this logic. } public void Commit() { + // https://github.com/dn-vm/dnvm/blob/main/src/dnvm/InstallCommand.cs#L393 + // Use the MIT license version of basically this logic. } public void Dispose() diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index da9c3c6fd4bc..72a0288cd655 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -17,7 +17,9 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) // Resolve strings or other options if (!dotnetVersion.IsValidMajorVersion()) { - // TODO ping the r-manifest to handle 'lts' 'latest' etc + // TODO ping the r-manifest to handle 'lts' 'latest' etc + // Do this in a separate class and use dotnet release library to do so + // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases } // Make sure the version is fully specified From 396db6fd128ffbbbfca57263e244d28d2771fbff Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 26 Aug 2025 16:49:31 -0700 Subject: [PATCH 034/153] prepare code from dnvm make sure to properly give credit in the real thing, we may want to avoid ifilesystem, but not sure yet. --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 136 +++++++++++++++++-- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 173ef7e9969a..666af3f78947 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -1,36 +1,150 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; -using System.Linq; - namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { + private string scratchDownloadDirectory; + private string scratchExtractionDirectory; + public ArchiveDotnetInstaller(DotnetInstall version) { - + scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; + scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } public void Prepare() { - // Create a user protected (wrx) random folder in temp - // Download the correct archive to the temp folder + // Download the archive to a user protected (wrx) random folder in temp + + // string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix); + // string archivePath = Path.Combine(scratchDownloadDirectory, archiveName); + + // Download to scratchDownloadDirectory + // Verify the hash and or signature of the archive - // https://github.com/dn-vm/dnvm/blob/e656f6e0011d4d710c94cb520d00604d9058460f/src/dnvm/InstallCommand.cs#L359C47-L359C62 - // Use the MIT license version of basically this logic. + // Extract to a temporary directory for the final replacement later. + // ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + } + + /** + private async Task ExtractArchive(string archivePath, IFileSystem extractionDirectory) + { + // TODO: Ensure this fails if the dir already exists as that's a security issue + extractionDirectory.CreateDirectory(tempExtractDir); + + using var tempRealPath = new DirectoryResource(extractionDirectory.ConvertPathToInternal(tempExtractDir)); + if (Utilities.CurrentRID.OS != OSPlatform.Windows) + { + // TODO: See if this works if 'tar' is unavailable + var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{tempRealPath.Path}\""); + if (procResult.ExitCode != 0) + { + return procResult.Error; + } + } + else + { + try + { + ZipFile.ExtractToDirectory(archivePath, tempRealPath.Path, overwriteFiles: true); + } + catch (Exception e) + { + return e.Message; + } + } + } + */ + + internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + { + return versionString is null + ? $"dotnet-sdk-{rid}{suffix}" + : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + } + + /** + public static async Task ExtractSdkToDir( + DotnetVersion? existingMuxerVersion, + DotnetVersion runtimeVersion, + string archivePath, + IFileSystem tempFs, + IFileSystem destFs, + string destDir) + { + destFs.CreateDirectory(destDir); + + try + { + // We want to copy over all the files from the extraction directory to the target + // directory, with one exception: the top-level "dotnet exe" (muxer). That has special logic. + CopyMuxer(existingMuxerVersion, runtimeVersion, tempFs, tempExtractDir, destFs, destDir); + + var extractFullName = tempExtractDir.FullName; + foreach (var dir in tempFs.EnumerateDirectories(tempExtractDir)) + { + destFs.CreateDirectory(Path.Combine(destDir, dir.GetName())); + foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories)) + { + var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/'); + var destPath = Path.Combine(destDir, relativePath); + + if (fsItem.IsDirectory) + { + destFs.CreateDirectory(destPath); + } + else + { + ForceReplaceFile(tempFs, fsItem.Path, destFs, destPath); + } + } + } + } + catch (Exception e) + { + return e.Message; + } + return null; + } + */ + + /** + private static void CopyMuxer( + DotnetVersion? existingMuxerVersion, + DotnetVersion newRuntimeVersion, + IFileSystem tempFs, + string tempExtractDir, + IFileSystem destFs, + string destDir) + { //The "dotnet" exe (muxer) is special in two ways: + // 1. It is shared between all SDKs, so it may be locked by another process. + // 2. It should always be the newest version, so we don't want to overwrite it if the SDK + // we're installing is older than the one already installed. + // + var muxerTargetPath = Path.Combine(destDir, DotnetExeName); + + if (newRuntimeVersion.CompareSortOrderTo(existingMuxerVersion) <= 0) + { + // The new SDK is older than the existing muxer, so we don't need to do anything. + return; + } + + // The new SDK is newer than the existing muxer, so we need to replace it. + ForceReplaceFile(tempFs, Path.Combine(tempExtractDir, DotnetExeName), destFs, muxerTargetPath); } + */ public void Commit() { - // https://github.com/dn-vm/dnvm/blob/main/src/dnvm/InstallCommand.cs#L393 - // Use the MIT license version of basically this logic. + //ExtractSdkToDir(); } public void Dispose() { - // Clean up the temp directory + File.Delete(scratchExtractionDirectory); + File.Delete(scratchDownloadDirectory); } } From e4b7e420bef75b7aa91b27fc88cb7c239d483a53 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 3 Sep 2025 12:46:59 -0700 Subject: [PATCH 035/153] Migrate to .NET Archive Libraries We should try to use our default libraries when possible instead of shelling out to the tar process. There were some issues with the library with null terminators, but they have been resolved in .net 10 preview 4. --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 51 +++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 666af3f78947..27f505e2abf8 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -14,7 +14,7 @@ public ArchiveDotnetInstaller(DotnetInstall version) scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } - public void Prepare() + public Prepare() { // Download the archive to a user protected (wrx) random folder in temp @@ -24,32 +24,59 @@ public void Prepare() // Download to scratchDownloadDirectory // Verify the hash and or signature of the archive + VerifyArchive(scratchDownloadDirectory); // Extract to a temporary directory for the final replacement later. - // ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); } /** - private async Task ExtractArchive(string archivePath, IFileSystem extractionDirectory) + Returns a string if the archive is valid within SDL specification, false otherwise. + */ + private void VerifyArchive(string archivePath) { - // TODO: Ensure this fails if the dir already exists as that's a security issue - extractionDirectory.CreateDirectory(tempExtractDir); + if (archivePath != null) // replace this with actual verification logic once its implemented. + { + throw new InvalidOperationException("Archive verification failed."); + } + } - using var tempRealPath = new DirectoryResource(extractionDirectory.ConvertPathToInternal(tempExtractDir)); + /** + Extracts the specified archive to the given extraction directory. + The archive will be decompressed if necessary. + Expects either a .tar.gz, .tar, or .zip archive. + */ + private string? ExtractArchive(string archivePath, string extractionDirectory) + { if (Utilities.CurrentRID.OS != OSPlatform.Windows) { - // TODO: See if this works if 'tar' is unavailable - var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{tempRealPath.Path}\""); - if (procResult.ExitCode != 0) + var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + using var archiveDecompressedPath = needsDecompression ? new DirectoryResource(archivePath) : new DirectoryResource(Path.Combine(archivePath), "decompressed"); + + // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + if (needsDecompression) { - return procResult.Error; + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(archiveDecompressedPath.Path); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + } + + try + { + TarFile.ExtractToDirectory(archiveDecompressedPath.Path, extractionDirectory, overwriteFiles: true); + // The temp folder will be cleaned out at class destruction time - no need to clean it now. + } + catch (Exception e) + { + return e.Message; } } else { try { - ZipFile.ExtractToDirectory(archivePath, tempRealPath.Path, overwriteFiles: true); + ZipFile.ExtractToDirectory(archivePath, extractionDirectory, overwriteFiles: true); } catch (Exception e) { @@ -57,7 +84,6 @@ public void Prepare() } } } - */ internal static string ConstructArchiveName(string? versionString, string rid, string suffix) { @@ -75,6 +101,7 @@ internal static string ConstructArchiveName(string? versionString, string rid, s IFileSystem destFs, string destDir) { + // Make sure the first task has finished destFs.CreateDirectory(destDir); try From 2f458c9bc2028187037293542a22b5a86342ba9f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 3 Sep 2025 16:52:36 -0700 Subject: [PATCH 036/153] Implement Extraction Logic --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 129 ++++++++++-------- src/Installer/dnup/DnupUtilities.cs | 24 ++++ src/Installer/dnup/DotnetInstall.cs | 11 +- .../dnup/InstallerOrchestratorSingleton.cs | 3 +- test/dnup.Tests/DotnetInstallTests.cs | 4 +- 5 files changed, 106 insertions(+), 65 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 27f505e2abf8..a6d08374b4eb 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -1,20 +1,33 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { + private readonly DotnetInstallRequest _request; + private readonly DotnetInstall _install; private string scratchDownloadDirectory; private string scratchExtractionDirectory; - public ArchiveDotnetInstaller(DotnetInstall version) + public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall version) { + _request = request; + _install = version; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } - public Prepare() + public void Prepare() { // Download the archive to a user protected (wrx) random folder in temp @@ -48,24 +61,31 @@ Extracts the specified archive to the given extraction directory. */ private string? ExtractArchive(string archivePath, string extractionDirectory) { - if (Utilities.CurrentRID.OS != OSPlatform.Windows) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - using var archiveDecompressedPath = needsDecompression ? new DirectoryResource(archivePath) : new DirectoryResource(Path.Combine(archivePath), "decompressed"); - - // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives - if (needsDecompression) - { - using FileStream originalFileStream = File.OpenRead(archivePath); - using FileStream decompressedFileStream = File.Create(archiveDecompressedPath.Path); - using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); - decompressionStream.CopyTo(decompressedFileStream); - } + string decompressedPath = archivePath; try { - TarFile.ExtractToDirectory(archiveDecompressedPath.Path, extractionDirectory, overwriteFiles: true); - // The temp folder will be cleaned out at class destruction time - no need to clean it now. + // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + if (needsDecompression) + { + decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(decompressedPath); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + } + + // Use System.Formats.Tar for .NET 7+ + TarFile.ExtractToDirectory(decompressedPath, extractionDirectory, overwriteFiles: true); + + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); + } } catch (Exception e) { @@ -83,6 +103,7 @@ Extracts the specified archive to the given extraction directory. return e.Message; } } + return null; } internal static string ConstructArchiveName(string? versionString, string rid, string suffix) @@ -92,41 +113,39 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - /** - public static async Task ExtractSdkToDir( - DotnetVersion? existingMuxerVersion, - DotnetVersion runtimeVersion, - string archivePath, - IFileSystem tempFs, - IFileSystem destFs, - string destDir) + + private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) { - // Make sure the first task has finished - destFs.CreateDirectory(destDir); + // Ensure destination directory exists + Directory.CreateDirectory(destDir); + + DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; + DotnetVersion runtimeVersion = _install.FullySpecifiedVersion; try { - // We want to copy over all the files from the extraction directory to the target - // directory, with one exception: the top-level "dotnet exe" (muxer). That has special logic. - CopyMuxer(existingMuxerVersion, runtimeVersion, tempFs, tempExtractDir, destFs, destDir); + CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - var extractFullName = tempExtractDir.FullName; - foreach (var dir in tempFs.EnumerateDirectories(tempExtractDir)) + foreach (var sourcePath in Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories)) { - destFs.CreateDirectory(Path.Combine(destDir, dir.GetName())); - foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories)) - { - var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/'); - var destPath = Path.Combine(destDir, relativePath); + var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); + var destPath = Path.Combine(destDir, relativePath); - if (fsItem.IsDirectory) - { - destFs.CreateDirectory(destPath); - } - else + if (File.Exists(sourcePath)) + { + // Skip dotnet.exe + if (string.Equals(Path.GetFileName(sourcePath), DnupUtilities.GetDotnetExeName(), StringComparison.OrdinalIgnoreCase)) { - ForceReplaceFile(tempFs, fsItem.Path, destFs, destPath); + continue; } + + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + DnupUtilities.ForceReplaceFile(sourcePath, destPath); + } + else if (Directory.Exists(sourcePath)) + { + // Merge directories: create if not exists, do not delete anything in dest + Directory.CreateDirectory(destPath); } } } @@ -136,37 +155,33 @@ internal static string ConstructArchiveName(string? versionString, string rid, s } return null; } - */ - /** - private static void CopyMuxer( - DotnetVersion? existingMuxerVersion, - DotnetVersion newRuntimeVersion, - IFileSystem tempFs, - string tempExtractDir, - IFileSystem destFs, - string destDir) - { //The "dotnet" exe (muxer) is special in two ways: + private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRuntimeVersion, string archiveDir, string destDir) + { + // The "dotnet" exe (muxer) is special in two ways: // 1. It is shared between all SDKs, so it may be locked by another process. // 2. It should always be the newest version, so we don't want to overwrite it if the SDK // we're installing is older than the one already installed. - // - var muxerTargetPath = Path.Combine(destDir, DotnetExeName); + var muxerTargetPath = Path.Combine(destDir, DnupUtilities.GetDotnetExeName()); - if (newRuntimeVersion.CompareSortOrderTo(existingMuxerVersion) <= 0) + if (existingMuxerVersion is not null && newRuntimeVersion.CompareTo(existingMuxerVersion) <= 0) { // The new SDK is older than the existing muxer, so we don't need to do anything. return; } // The new SDK is newer than the existing muxer, so we need to replace it. - ForceReplaceFile(tempFs, Path.Combine(tempExtractDir, DotnetExeName), destFs, muxerTargetPath); + DnupUtilities.ForceReplaceFile(Path.Combine(archiveDir, DnupUtilities.GetDotnetExeName()), muxerTargetPath); } - */ public void Commit() { - //ExtractSdkToDir(); + Commit(existingSdkVersions: Enumerable.Empty()); // todo impl this + } + + public void Commit(IEnumerable existingSdkVersions) + { + ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions); } public void Dispose() diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 06d8899efe56..2e09f6ac7f7b 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -3,12 +3,21 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper; internal static class DnupUtilities { + public static string ExeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""; + + public static string GetDotnetExeName() + { + return "dotnet" + ExeSuffix; + } + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) { return architecture switch @@ -19,4 +28,19 @@ public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropS _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") }; } + + public static void ForceReplaceFile(string sourcePath, string destPath) + { + File.Copy(sourcePath, destPath, overwrite: true); + + // Copy file attributes + var srcInfo = new FileInfo(sourcePath); + var dstInfo = new FileInfo(destPath) + { + CreationTimeUtc = srcInfo.CreationTimeUtc, + LastWriteTimeUtc = srcInfo.LastWriteTimeUtc, + LastAccessTimeUtc = srcInfo.LastAccessTimeUtc, + Attributes = srcInfo.Attributes + }; + } } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index a867b4f66fe8..f6c387b85188 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -19,20 +19,21 @@ internal record DotnetInstallBase( /// /// Represents a .NET installation with a fully specified version. +/// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// internal record DotnetInstall( - string FullySpecifiedVersion, - string ResolvedDirectory, + DotnetVersion FullySpecifiedVersion, + string MuxerDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// internal record DotnetInstallRequest( string ChannelVersion, - string ResolvedDirectory, + string TargetDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(ResolvedDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index e323e2f5399c..45a469cc90d4 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -24,6 +24,7 @@ public int Install(DotnetInstallRequest installRequest) DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); // Check if the install already exists and we don't need to do anything + // read write mutex only for manifest? using (var finalizeLock = modifyInstallStateMutex()) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) @@ -32,7 +33,7 @@ public int Install(DotnetInstallRequest installRequest) } } - ArchiveDotnetInstaller installer = new(install); + ArchiveDotnetInstaller installer = new(installRequest, install); installer.Prepare(); // Extract and commit the install to the directory diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs index 5e4e90812974..68f70ad57949 100644 --- a/test/dnup.Tests/DotnetInstallTests.cs +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -33,9 +33,9 @@ public void DotnetInstall_ShouldInheritFromBase() var mode = InstallMode.SDK; var architecture = InstallArchitecture.x64; - var install = new DotnetInstall(version, directory, type, mode, architecture); + var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture); - install.FullySpecifiedVersion.Should().Be(version); + install.FullySpecifiedVersion.Value.Should().Be(version); install.ResolvedDirectory.Should().Be(directory); install.Type.Should().Be(type); install.Mode.Should().Be(mode); From 93bc61fd3624561477f17c1d938c78dff37fee7b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 12:03:19 -0700 Subject: [PATCH 037/153] Implement manifest parsing and vscode setting folder for build button --- src/Installer/dnup/.gitignore | 2 + src/Installer/dnup/.vscode/launch.json | 30 ++ src/Installer/dnup/.vscode/tasks.json | 56 +++ src/Installer/dnup/ArchiveDotnetInstaller.cs | 29 +- src/Installer/dnup/BootstrapperController.cs | 3 +- src/Installer/dnup/DnupUtilities.cs | 29 ++ src/Installer/dnup/DotnetInstall.cs | 8 +- src/Installer/dnup/ReleaseManifest.cs | 501 +++++++++++++++++++ 8 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 src/Installer/dnup/.gitignore create mode 100644 src/Installer/dnup/.vscode/launch.json create mode 100644 src/Installer/dnup/.vscode/tasks.json create mode 100644 src/Installer/dnup/ReleaseManifest.cs diff --git a/src/Installer/dnup/.gitignore b/src/Installer/dnup/.gitignore new file mode 100644 index 000000000000..98ad9206b3e8 --- /dev/null +++ b/src/Installer/dnup/.gitignore @@ -0,0 +1,2 @@ +# Override the root .gitignore to NOT ignore the .vscode folder +!.vscode/ diff --git a/src/Installer/dnup/.vscode/launch.json b/src/Installer/dnup/.vscode/launch.json new file mode 100644 index 000000000000..24246299b009 --- /dev/null +++ b/src/Installer/dnup/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": "${input:commandLineArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "requireExactSource": false + } + ], + "inputs": [ + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/.vscode/tasks.json b/src/Installer/dnup/.vscode/tasks.json new file mode 100644 index 000000000000..541939d1dae4 --- /dev/null +++ b/src/Installer/dnup/.vscode/tasks.json @@ -0,0 +1,56 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/dnup.csproj", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + } + ], + "inputs": [ + { + "id": "buildArgs", + "type": "promptString", + "description": "Additional build arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index a6d08374b4eb..8e8ca5660c3e 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -8,7 +8,6 @@ using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; -using System.Threading.Tasks; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -29,18 +28,23 @@ public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall versio public void Prepare() { - // Download the archive to a user protected (wrx) random folder in temp - - // string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix); - // string archivePath = Path.Combine(scratchDownloadDirectory, archiveName); - - // Download to scratchDownloadDirectory - - // Verify the hash and or signature of the archive - VerifyArchive(scratchDownloadDirectory); + using var releaseManifest = new ReleaseManifest(); + var archiveName = $"dotnet-{_install.Id}"; + var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + // Download the archive with hash verification using the DotNet Releases library + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); + } + // Extract to a temporary directory for the final replacement later. - ExtractArchive(scratchDownloadDirectory, scratchExtractionDirectory); + var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); + } } /** @@ -48,7 +52,7 @@ public void Prepare() */ private void VerifyArchive(string archivePath) { - if (archivePath != null) // replace this with actual verification logic once its implemented. + if (!File.Exists(archivePath)) // replace this with actual verification logic once its implemented. { throw new InvalidOperationException("Archive verification failed."); } @@ -113,7 +117,6 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) { // Ensure destination directory exists diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index a50c73b6d541..e4cb8d2e25de 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -117,7 +117,8 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri InstallType.User, InstallMode.SDK, // Get current machine architecture and convert it to correct enum value - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture) + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + new InstallRequestOptions() ); InstallerOrchestratorSingleton.Instance.Install(request); diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 2e09f6ac7f7b..e759ee3eea46 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -43,4 +43,33 @@ public static void ForceReplaceFile(string sourcePath, string destPath) Attributes = srcInfo.Attributes }; } + + public static string GetRuntimeIdentifier(InstallArchitecture architecture) + { + var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" : "unknown"; + + var arch = architecture switch + { + InstallArchitecture.x64 => "x64", + InstallArchitecture.x86 => "x86", + InstallArchitecture.arm64 => "arm64", + _ => "x64" // Default fallback + }; + + return $"{os}-{arch}"; + } + + public static string GetFileExtensionForPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ".zip"; // Windows typically uses zip archives + } + else + { + return ".tar.gz"; // Unix-like systems use tar.gz + } + } } diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index f6c387b85188..50819d3ecad1 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -17,6 +17,11 @@ internal record DotnetInstallBase( public Guid Id { get; } = Guid.NewGuid(); } +internal record InstallRequestOptions() +{ + // Include things such as the custom feed here. +} + /// /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. @@ -36,4 +41,5 @@ internal record DotnetInstallRequest( string TargetDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); + InstallArchitecture Architecture, + InstallRequestOptions Options) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs new file mode 100644 index 000000000000..0e186df0db65 --- /dev/null +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -0,0 +1,501 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. +/// +internal class ReleaseManifest : IDisposable +{ + private const string CacheSubdirectory = "dotnet-manifests"; + private const int MaxRetryCount = 3; + private const int RetryDelayMilliseconds = 1000; + private const string ReleaseCacheMutexName = "Global\\DotNetReleaseCache"; + + private readonly HttpClient _httpClient; + private readonly string _cacheDirectory; + private ProductCollection? _productCollection; + + public ReleaseManifest() + : this(CreateDefaultHttpClient(), GetDefaultCacheDirectory()) + { + } + + public ReleaseManifest(HttpClient httpClient) + : this(httpClient, GetDefaultCacheDirectory()) + { + } + + public ReleaseManifest(HttpClient httpClient, string cacheDirectory) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _cacheDirectory = cacheDirectory ?? throw new ArgumentNullException(nameof(cacheDirectory)); + + // Ensure cache directory exists + Directory.CreateDirectory(_cacheDirectory); + } + + /// + /// Creates an HttpClient with enhanced proxy support for enterprise environments. + /// + private static HttpClient CreateDefaultHttpClient() + { + var handler = new HttpClientHandler() + { + // Use system proxy settings by default + UseProxy = true, + // Use default credentials for proxy authentication if needed + UseDefaultCredentials = true, + // Handle redirects automatically + AllowAutoRedirect = true, + // Set maximum number of redirects to prevent infinite loops + MaxAutomaticRedirections = 10, + // Enable decompression for better performance + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + + var client = new HttpClient(handler) + { + // Set a reasonable timeout for downloads + Timeout = TimeSpan.FromMinutes(10) + }; + + // Set user agent to identify the client + client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer"); + + return client; + } + + /// + /// Gets the default cache directory path. + /// + private static string GetDefaultCacheDirectory() + { + var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(baseDir, "dnup", CacheSubdirectory); + } + + /// + /// Downloads the releases.json manifest and finds the download URL for the specified installation. + /// + /// The .NET installation details + /// The download URL for the installer/archive, or null if not found + public string? GetDownloadUrl(DotnetInstall install) + { + var targetFile = FindReleaseFile(install); + return targetFile?.Address.ToString(); + } + + /// + /// Downloads the archive from the specified URL to the destination path with progress reporting. + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + public async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) + { + // Create temp file path in same directory for atomic move when complete + string tempPath = $"{destinationPath}.download"; + + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + // Ensure the directory exists + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + // Try to get content length for progress reporting + long? totalBytes = await GetContentLengthAsync(downloadUrl); + + // Make the actual download request + using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + // Get the total bytes if we didn't get it before + if (!totalBytes.HasValue && response.Content.Headers.ContentLength.HasValue) + { + totalBytes = response.Content.Headers.ContentLength.Value; + } + + using var contentStream = await response.Content.ReadAsStreamAsync(); + using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[81920]; // 80KB buffer + long bytesRead = 0; + int read; + + var lastProgressReport = DateTime.UtcNow; + + while ((read = await contentStream.ReadAsync(buffer)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read)); + + bytesRead += read; + + // Report progress at most every 100ms to avoid UI thrashing + var now = DateTime.UtcNow; + if ((now - lastProgressReport).TotalMilliseconds > 100) + { + lastProgressReport = now; + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + } + } + + // Final progress report + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + + // Ensure all data is written to disk + await fileStream.FlushAsync(); + fileStream.Close(); + + // Atomic move to final destination + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + File.Move(tempPath, destinationPath); + + return true; + } + catch (Exception) + { + // Delete the partial download if it exists + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Ignore cleanup errors + } + + if (attempt < MaxRetryCount) + { + await Task.Delay(RetryDelayMilliseconds * attempt); // Exponential backoff + } + else + { + return false; + } + } + } + + return false; + } + + /// + /// Gets the content length of a resource. + /// + private async Task GetContentLengthAsync(string url) + { + try + { + using var headRequest = new HttpRequestMessage(HttpMethod.Head, url); + using var headResponse = await _httpClient.SendAsync(headRequest); + return headResponse.Content.Headers.ContentLength; + } + catch + { + return null; + } + } + + /// + /// Downloads the archive from the specified URL to the destination path (synchronous version). + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + public bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) + { + return DownloadArchiveAsync(downloadUrl, destinationPath, progress).GetAwaiter().GetResult(); + } + + /// + /// Downloads the archive for the specified installation and verifies its hash. + /// + /// The .NET installation details + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download and verification were successful, false otherwise + public bool DownloadArchiveWithVerification(DotnetInstall install, string destinationPath, IProgress? progress = null) + { + // Get the download URL and expected hash + string? downloadUrl = GetDownloadUrl(install); + if (string.IsNullOrEmpty(downloadUrl)) + { + return false; + } + + string? expectedHash = GetArchiveHash(install); + if (string.IsNullOrEmpty(expectedHash)) + { + return false; + } + + if (!DownloadArchive(downloadUrl, destinationPath, progress)) + { + return false; + } + + return VerifyFileHash(destinationPath, expectedHash); + } + + /// + /// Finds the appropriate release file for the given installation. + /// + /// The .NET installation details + /// The matching ReleaseFile, throws if none are available. + private ReleaseFile? FindReleaseFile(DotnetInstall install) + { + try + { + var productCollection = GetProductCollection(); + var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value); + if (product == null) return null; + + var release = FindRelease(product, install.FullySpecifiedVersion.Value); + if (release == null) return null; + + return FindMatchingFile(release, install); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to find an available release for install {install} : ${ex.Message}"); + } + } + + /// + /// Gets or loads the ProductCollection with caching. + /// + private ProductCollection GetProductCollection() + { + if (_productCollection != null) + { + return _productCollection; + } + + // Use ScopedMutex for cross-process locking + using var mutex = new ScopedMutex(ReleaseCacheMutexName); + + if (!mutex.HasHandle) + { + // If we couldn't acquire the mutex, still try to load the collection + // but don't write to the cache file to avoid conflicts + return _productCollection ??= ProductCollection.GetAsync().GetAwaiter().GetResult(); + } + + // Double-check locking pattern + if (_productCollection != null) + { + return _productCollection; + } + + string cacheFilePath = Path.Combine(_cacheDirectory, "releases.json"); + bool useCachedData = false; + + if (File.Exists(cacheFilePath)) + { + var cacheFileAge = File.GetLastWriteTimeUtc(cacheFilePath); + // If cache exists and is less than 24 hours old, use it + useCachedData = (DateTime.UtcNow - cacheFileAge).TotalHours < 24; + } + + if (useCachedData) + { + try + { + string json = File.ReadAllText(cacheFilePath); + _productCollection = DeserializeProductCollection(json); + return _productCollection; + } + catch + { + // Continue to fetch fresh data if cache loading fails + } + } + + // Fetch fresh data with retry logic + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + try + { + string json = SerializeProductCollection(_productCollection); + File.WriteAllText(cacheFilePath, json); + } + catch + { + // Continue since we have the data in memory + } + + return _productCollection; + } + catch + { + if (attempt == MaxRetryCount) + { + throw; + } + + Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff + } + } + + // This shouldn't be reached due to throw above, but compiler doesn't know that + throw new InvalidOperationException("Failed to fetch .NET releases data"); + } + + /// + /// Serializes a ProductCollection to JSON. + /// + private static string SerializeProductCollection(ProductCollection collection) + { + // Use options that indicate we've verified AOT compatibility + var options = new System.Text.Json.JsonSerializerOptions(); +#pragma warning disable IL2026, IL3050 + return System.Text.Json.JsonSerializer.Serialize(collection, options); +#pragma warning restore IL2026, IL3050 + } + + /// + /// Deserializes a ProductCollection from JSON. + /// + private static ProductCollection DeserializeProductCollection(string json) + { + // Use options that indicate we've verified AOT compatibility + var options = new System.Text.Json.JsonSerializerOptions(); +#pragma warning disable IL2026, IL3050 + return System.Text.Json.JsonSerializer.Deserialize(json, options) + ?? throw new InvalidOperationException("Failed to deserialize ProductCollection from JSON"); +#pragma warning restore IL2026, IL3050 + } + + /// + /// Finds the product for the given version. + /// + private static Product? FindProduct(ProductCollection productCollection, string version) + { + var releaseVersion = new ReleaseVersion(version); + var majorMinor = $"{releaseVersion.Major}.{releaseVersion.Minor}"; + return productCollection.FirstOrDefault(p => p.ProductVersion == majorMinor); + } + + /// + /// Finds the specific release for the given version. + /// + private static ProductRelease? FindRelease(Product product, string version) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + var targetReleaseVersion = new ReleaseVersion(version); + return releases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + } + + /// + /// Finds the matching file in the release for the given installation requirements. + /// + private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstall install) + { + var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); + var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); + var componentType = install.Mode == InstallMode.SDK ? "sdk" : "runtime"; + + return release.Files + .Where(f => f.Rid == rid) + .Where(f => f.Name.Contains(componentType, StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Gets the SHA512 hash of the archive for the specified installation. + /// + /// The .NET installation details + /// The SHA512 hash string of the installer/archive, or null if not found + public string? GetArchiveHash(DotnetInstall install) + { + var targetFile = FindReleaseFile(install); + return targetFile?.Hash; + } + + /// + /// Computes the SHA512 hash of a file. + /// + /// Path to the file to hash + /// The hash as a lowercase hex string + public static string ComputeFileHash(string filePath) + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using var sha512 = SHA512.Create(); + byte[] hashBytes = sha512.ComputeHash(fileStream); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Verifies that a downloaded file matches the expected hash. + /// + /// Path to the file to verify + /// Expected hash value + /// True if the hash matches, false otherwise + public static bool VerifyFileHash(string filePath, string expectedHash) + { + if (string.IsNullOrEmpty(expectedHash)) + { + return false; + } + + string actualHash = ComputeFileHash(filePath); + return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} + +/// +/// Represents download progress information. +/// +public readonly struct DownloadProgress +{ + /// + /// Gets the number of bytes downloaded. + /// + public long BytesDownloaded { get; } + + /// + /// Gets the total number of bytes to download, if known. + /// + public long? TotalBytes { get; } + + /// + /// Gets the percentage of download completed, if total size is known. + /// + public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; + + public DownloadProgress(long bytesDownloaded, long? totalBytes) + { + BytesDownloaded = bytesDownloaded; + TotalBytes = totalBytes; + } +} From 00ff09d4591eead24f943da44e095b45211796fa Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:00:19 -0700 Subject: [PATCH 038/153] Installing .NET works e2e --- src/Installer/dnup/.vscode/settings.json | 18 +++ src/Installer/dnup/BootstrapperController.cs | 10 +- .../EnvironmentVariableMockDotnetInstaller.cs | 92 +++++++++++ ...ironmentVariableMockReleaseInfoProvider.cs | 49 ++++++ .../Commands/Sdk/Install/SdkInstallCommand.cs | 153 +----------------- .../dnup/InstallerOrchestratorSingleton.cs | 12 +- src/Installer/dnup/dnup.code-workspace | 60 +++++++ src/Installer/dnup/dnup.sln | 24 +++ src/Installer/installer.code-workspace | 8 + 9 files changed, 269 insertions(+), 157 deletions(-) create mode 100644 src/Installer/dnup/.vscode/settings.json create mode 100644 src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs create mode 100644 src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs create mode 100644 src/Installer/dnup/dnup.code-workspace create mode 100644 src/Installer/dnup/dnup.sln create mode 100644 src/Installer/installer.code-workspace diff --git a/src/Installer/dnup/.vscode/settings.json b/src/Installer/dnup/.vscode/settings.json new file mode 100644 index 000000000000..c9127932ea34 --- /dev/null +++ b/src/Installer/dnup/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "dotnet.defaultSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true, + "dotnetAcquisitionExtension.existingDotnetPath": [ + { + "extensionId": "ms-dotnettools.csharp", + "path": "C:\\Program Files\\dotnet\\dotnet.exe" + } + ], + "launch": { + "configurations": [], + "compounds": [] + }, + "omnisharp.defaultLaunchSolution": "dnup.csproj" +} \ No newline at end of file diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index e4cb8d2e25de..acf575acffed 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -121,7 +121,15 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri new InstallRequestOptions() ); - InstallerOrchestratorSingleton.Instance.Install(request); + DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); + if (newInstall == null) + { + throw new Exception($"Failed to install .NET SDK {channelVersion}"); + } + else + { + progressContext.AddTask($"Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}"); + } } public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs new file mode 100644 index 000000000000..50004365e49c --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Spectre.Console; + +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockDotnetInstaller : IBootstrapperController + { + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + return new GlobalJsonInfo + { + GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), + GlobalJsonContents = null // Set to null for test mock; update as needed for tests + }; + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + public InstallType GetConfiguredInstallType(out string? currentInstallPath) + { + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + InstallType returnValue = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + { + returnValue = InstallType.None; + } + currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return returnValue; + } + + public string? GetLatestInstalledAdminVersion() + { + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.0-preview.7"; + } + return latestAdminVersion; + } + + public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + using (var httpClient = new HttpClient()) + { + List downloads = sdkVersions.Select(version => + { + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + var task = progressContext.AddTask($"Downloading .NET SDK {version}"); + return (Action)(() => + { + Download(downloadLink, httpClient, task); + }); + }).ToList(); + + foreach (var download in downloads) + { + download(); + } + } + } + + void Download(string url, HttpClient httpClient, ProgressTask task) + { + for (int i = 0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) + { + SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); + } + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) + { + SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs new file mode 100644 index 000000000000..681e0d40ac0d --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider + { + public List GetAvailableChannels() + { + var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); + if (string.IsNullOrEmpty(channels)) + { + return new List { "latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx" }; + } + return channels.Split(',').ToList(); + } + public string GetLatestVersion(string channel) + { + if (channel == "preview") + { + return "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + return "10.0.0-preview.7"; + } + else if (channel == "10.0.1xx") + { + return "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + return "9.0.309"; + } + else if (channel == "9.0.2xx") + { + return "9.0.212"; + } + else if (channel == "9.0.1xx") + { + return "9.0.115"; + } + + return channel; + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 5909cddfe02b..d54508c7cdb6 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -8,6 +8,7 @@ using SpectreAnsiConsole = Spectre.Console.AnsiConsole; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; @@ -19,7 +20,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); - private readonly IBootstrapperController _dotnetInstaller = new EnvironmentVariableMockDotnetInstaller(); + private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); public override int Execute() @@ -209,10 +210,7 @@ public override int Execute() } } - // TODO: Implement transaction / rollback? - // TODO: Use Mutex to avoid concurrent installs? - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); @@ -253,150 +251,5 @@ bool IsElevated() return false; } - class EnvironmentVariableMockDotnetInstaller : IBootstrapperController - { - public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) - { - return new GlobalJsonInfo - { - GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), - GlobalJsonContents = null // Set to null for test mock; update as needed for tests - }; - } - - public string GetDefaultDotnetInstallPath() - { - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); - } - - public InstallType GetConfiguredInstallType(out string? currentInstallPath) - { - var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - InstallType returnValue = InstallType.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) - { - returnValue = InstallType.None; - } - currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return returnValue; - } - - public string? GetLatestInstalledAdminVersion() - { - var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); - if (string.IsNullOrEmpty(latestAdminVersion)) - { - latestAdminVersion = "10.0.203"; - } - return latestAdminVersion; - } - - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) - { - //var task = progressContext.AddTask($"Downloading .NET SDK {resolvedChannelVersion}"); - using (var httpClient = new System.Net.Http.HttpClient()) - { - List downloads = sdkVersions.Select(version => - { - string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; - var task = progressContext.AddTask($"Downloading .NET SDK {version}"); - return (Action)(() => - { - Download(downloadLink, httpClient, task); - }); - }).ToList(); - - - foreach (var download in downloads) - { - download(); - } - } - } - - void Download(string url, HttpClient httpClient, ProgressTask task) - { - //string tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(url)); - //using (var response = httpClient.GetAsync(url, System.Net.Http.HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult()) - //{ - // response.EnsureSuccessStatusCode(); - // var contentLength = response.Content.Headers.ContentLength ?? 0; - // using (var stream = response.Content.ReadAsStream()) - // using (var fileStream = File.Create(tempFilePath)) - // { - // var buffer = new byte[81920]; - // long totalRead = 0; - // int read; - // while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) - // { - // fileStream.Write(buffer, 0, read); - // totalRead += read; - // if (contentLength > 0) - // { - // task.Value = (double)totalRead / contentLength * 100; - // } - // } - // task.Value = 100; - // } - //} - - for (int i = 0; i < 100; i++) - { - task.Increment(1); - Thread.Sleep(20); // Simulate some work - } - task.Value = 100; - } - - public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) - { - SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); - } - public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) - { - SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); - } - } - - class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider - { - public List GetAvailableChannels() - { - var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); - if (string.IsNullOrEmpty(channels)) - { - return ["latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx"]; - } - return channels.Split(',').ToList(); - } - public string GetLatestVersion(string channel) - { - if (channel == "preview") - { - return "11.0.100-preview.1.42424"; - } - else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") - { - return "10.0.203"; - } - else if (channel == "10.0.1xx") - { - return "10.0.106"; - } - else if (channel == "9" || channel == "9.0.3xx") - { - return "9.0.309"; - } - else if (channel == "9.0.2xx") - { - return "9.0.212"; - } - else if (channel == "9.0.1xx") - { - return "9.0.115"; - } - - return channel; - } - } + // ...existing code... } diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 45a469cc90d4..639816faa015 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -18,7 +18,8 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); - public int Install(DotnetInstallRequest installRequest) + // Returns null on failure, DotnetInstall on success + public DotnetInstall? Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); @@ -29,7 +30,7 @@ public int Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return 0; + return install; } } @@ -41,7 +42,7 @@ public int Install(DotnetInstallRequest installRequest) { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { - return 0; + return install; } installer.Commit(); @@ -54,12 +55,11 @@ public int Install(DotnetInstallRequest installRequest) } else { - // TODO Handle validation failure better - return 1; + return null; } } - return 0; + return install; } // Add a doc string mentioning you must hold a mutex over the directory diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace new file mode 100644 index 000000000000..fca5689a68dc --- /dev/null +++ b/src/Installer/dnup/dnup.code-workspace @@ -0,0 +1,60 @@ +{ + "folders": [ + { + "path": ".", + "name": "dnup" + } + ], + "settings": { + "dotnet.defaultSolution": "dnup.csproj", + "omnisharp.defaultLaunchSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder}", + "console": "externalTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + } + ], + "compounds": [] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + } + ] + } +} diff --git a/src/Installer/dnup/dnup.sln b/src/Installer/dnup/dnup.sln new file mode 100644 index 000000000000..f8be3f0124a7 --- /dev/null +++ b/src/Installer/dnup/dnup.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dnup", "dnup.csproj", "{FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE797DD7-D006-4A3F-94F3-1ED339F75821} + EndGlobalSection +EndGlobal diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace new file mode 100644 index 000000000000..dcf51a098081 --- /dev/null +++ b/src/Installer/installer.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "dnup" + } + ], + "settings": {} +} From 3fcf146c2e7c9ce1d40185ac8051089c730083a6 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:19:58 -0700 Subject: [PATCH 039/153] Show progress correctly --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 135 +++++++++++++----- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +- .../dnup/SpectreDownloadProgressReporter.cs | 45 ++++++ 3 files changed, 142 insertions(+), 44 deletions(-) create mode 100644 src/Installer/dnup/SpectreDownloadProgressReporter.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 8e8ca5660c3e..052c857f9248 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -32,19 +32,27 @@ public void Prepare() var archiveName = $"dotnet-{_install.Id}"; var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); - // Download the archive with hash verification using the DotNet Releases library - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath); - if (!downloadSuccess) - { - throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); - } - - // Extract to a temporary directory for the final replacement later. - var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory); - if (extractResult != null) - { - throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); - } + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath, reporter); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); + } + + downloadTask.Value = 100; + + var extractTask = ctx.AddTask($"Extracting .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory, extractTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); + } + extractTask.Value = extractTask.MaxValue; + }); } /** @@ -63,16 +71,14 @@ Extracts the specified archive to the given extraction directory. The archive will be decompressed if necessary. Expects either a .tar.gz, .tar, or .zip archive. */ - private string? ExtractArchive(string archivePath, string extractionDirectory) + private string? ExtractArchive(string archivePath, string extractionDirectory, Spectre.Console.ProgressTask extractTask) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + try { - var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - string decompressedPath = archivePath; - - try + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Run gzip decompression iff .gz is at the end of the archive file, which is true for .NET archives + var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + string decompressedPath = archivePath; if (needsDecompression) { decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); @@ -81,31 +87,68 @@ Extracts the specified archive to the given extraction directory. using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); decompressionStream.CopyTo(decompressedFileStream); } - - // Use System.Formats.Tar for .NET 7+ - TarFile.ExtractToDirectory(decompressedPath, extractionDirectory, overwriteFiles: true); - + // Count files in tar + long totalFiles = 0; + using (var tarStream = File.OpenRead(decompressedPath)) + { + var tarReader = new System.Formats.Tar.TarReader(tarStream); + while (tarReader.GetNextEntry() != null) + { + totalFiles++; + } + } + if (extractTask != null) + { + extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + using (var tarStream = File.OpenRead(decompressedPath)) + { + var tarReader = new System.Formats.Tar.TarReader(tarStream); + System.Formats.Tar.TarEntry? entry; + while ((entry = tarReader.GetNextEntry()) != null) + { + if (entry.EntryType == System.Formats.Tar.TarEntryType.RegularFile) + { + var outPath = Path.Combine(extractionDirectory, entry.Name); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + using var outStream = File.Create(outPath); + entry.DataStream?.CopyTo(outStream); + } + extractTask.Increment(1); + } + } + } // Clean up temporary decompressed file if (needsDecompression && File.Exists(decompressedPath)) { File.Delete(decompressedPath); } } - catch (Exception e) + else { - return e.Message; + long totalFiles = 0; + using (var zip = ZipFile.OpenRead(archivePath)) + { + totalFiles = zip.Entries.Count; + } + if (extractTask != null) + { + extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + using (var zip = ZipFile.OpenRead(archivePath)) + { + foreach (var entry in zip.Entries) + { + var outPath = Path.Combine(extractionDirectory, entry.FullName); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + entry.ExtractToFile(outPath, overwrite: true); + extractTask.Increment(1); + } + } + } } } - else + catch (Exception e) { - try - { - ZipFile.ExtractToDirectory(archivePath, extractionDirectory, overwriteFiles: true); - } - catch (Exception e) - { - return e.Message; - } + return e.Message; } return null; } @@ -117,7 +160,7 @@ internal static string ConstructArchiveName(string? versionString, string rid, s : $"dotnet-sdk-{versionString}-{rid}{suffix}"; } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions) + private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? commitTask = null, List? files = null) { // Ensure destination directory exists Directory.CreateDirectory(destDir); @@ -128,8 +171,9 @@ internal static string ConstructArchiveName(string? versionString, string rid, s try { CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - - foreach (var sourcePath in Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories)) + var fileList = files ?? Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories).ToList(); + int processed = 0; + foreach (var sourcePath in fileList) { var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); var destPath = Path.Combine(destDir, relativePath); @@ -150,6 +194,11 @@ internal static string ConstructArchiveName(string? versionString, string rid, s // Merge directories: create if not exists, do not delete anything in dest Directory.CreateDirectory(destPath); } + processed++; + if (commitTask != null) + { + commitTask.Value = processed; + } } } catch (Exception e) @@ -184,7 +233,15 @@ public void Commit() public void Commit(IEnumerable existingSdkVersions) { - ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions); + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var files = Directory.EnumerateFileSystemEntries(scratchExtractionDirectory, "*", SearchOption.AllDirectories).ToList(); + var commitTask = ctx.AddTask($"Installing .NET SDK", autoStart: true); + commitTask.MaxValue = files.Count > 0 ? files.Count : 1; + ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions, commitTask, files); + commitTask.Value = commitTask.MaxValue; + }); } public void Dispose() diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index d54508c7cdb6..8e02dfb8c9ed 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -214,11 +214,7 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - SpectreAnsiConsole.Progress() - .Start(ctx => - { - _dotnetInstaller.InstallSdks(resolvedInstallPath, ctx, new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); - }); + _dotnetInstaller.InstallSdks(resolvedInstallPath, SpectreAnsiConsole.Progress().Start(ctx => ctx), new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); if (resolvedSetDefaultInstall == true) { diff --git a/src/Installer/dnup/SpectreDownloadProgressReporter.cs b/src/Installer/dnup/SpectreDownloadProgressReporter.cs new file mode 100644 index 000000000000..f943e140e254 --- /dev/null +++ b/src/Installer/dnup/SpectreDownloadProgressReporter.cs @@ -0,0 +1,45 @@ +using System; +using Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public class SpectreDownloadProgressReporter : IProgress + { + private readonly ProgressTask _task; + private readonly string _description; + private long? _totalBytes; + + public SpectreDownloadProgressReporter(ProgressTask task, string description) + { + _task = task; + _description = description; + } + + public void Report(DownloadProgress value) + { + if (value.TotalBytes.HasValue) + { + _totalBytes = value.TotalBytes; + } + if (_totalBytes.HasValue && _totalBytes.Value > 0) + { + double percent = (double)value.BytesDownloaded / _totalBytes.Value * 100.0; + _task.Value = percent; + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)} / {FormatBytes(_totalBytes.Value)})"; + } + else + { + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)})"; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes > 1024 * 1024) + return $"{bytes / (1024 * 1024)} MB"; + if (bytes > 1024) + return $"{bytes / 1024} KB"; + return $"{bytes} B"; + } + } +} From 0a668b4c89bffbfd8f0f37ca17729126ead7f116 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:23:38 -0700 Subject: [PATCH 040/153] fix end message --- src/Installer/dnup/BootstrapperController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index acf575acffed..20d8dd9e0308 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -128,7 +128,7 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri } else { - progressContext.AddTask($"Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}"); + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}[/]"); } } From df9fde0f31e58fce2d3b3b6e615bfc694fc56874 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 4 Sep 2025 16:29:47 -0700 Subject: [PATCH 041/153] find available SDKs --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 42 +++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 052c857f9248..a294d6973150 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -228,7 +228,7 @@ private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRun public void Commit() { - Commit(existingSdkVersions: Enumerable.Empty()); // todo impl this + Commit(GetExistingSdkVersions(_request.TargetDirectory)); } public void Commit(IEnumerable existingSdkVersions) @@ -249,4 +249,44 @@ public void Dispose() File.Delete(scratchExtractionDirectory); File.Delete(scratchDownloadDirectory); } + + // This should be cached and more sophisticated based on vscode logic in the future + private IEnumerable GetExistingSdkVersions(string targetDirectory) + { + var dotnetExe = Path.Combine(targetDirectory, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetExe)) + return Enumerable.Empty(); + + try + { + var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = dotnetExe; + process.StartInfo.Arguments = "--list-sdks"; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + var versions = new List(); + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' '); + if (parts.Length > 0) + { + var versionStr = parts[0]; + if (DotnetVersion.TryParse(versionStr, out var version)) + { + versions.Add(version); + } + } + } + return versions; + } + catch + { + return Enumerable.Empty(); + } + } } From 716e6c2ed35093e8d40682d9c556a5cacb0d313f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:15:39 -0700 Subject: [PATCH 042/153] Add update cadence method for manifest tracking the objects should be public to be json-ifiable --- src/Installer/dnup/BootstrapperController.cs | 3 +- src/Installer/dnup/Constants.cs | 22 ++++++++++++++ src/Installer/dnup/DotnetInstall.cs | 8 +++-- src/Installer/dnup/DotnetVersion.cs | 4 +-- src/Installer/dnup/IDnupManifest.cs | 2 +- src/Installer/dnup/InstallArchitecture.cs | 2 +- src/Installer/dnup/InstallMode.cs | 2 +- .../dnup/InstallerOrchestratorSingleton.cs | 2 +- src/Installer/dnup/ManagementCadence.cs | 29 +++++++++++++++++++ .../dnup/ManifestChannelVersionResolver.cs | 3 +- 10 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/Installer/dnup/Constants.cs create mode 100644 src/Installer/dnup/ManagementCadence.cs diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index 20d8dd9e0308..d1f431d172f1 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -117,7 +117,8 @@ private void InstallSDK(string dotnetRoot, ProgressContext progressContext, stri InstallType.User, InstallMode.SDK, // Get current machine architecture and convert it to correct enum value - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), new InstallRequestOptions() ); diff --git a/src/Installer/dnup/Constants.cs b/src/Installer/dnup/Constants.cs new file mode 100644 index 000000000000..007364d7b8bc --- /dev/null +++ b/src/Installer/dnup/Constants.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + /// + /// Shared constants for the dnup application. + /// + internal static class Constants + { + /// + /// Mutex names used for synchronization. + /// + public static class MutexNames + { + /// + /// Mutex used during the final installation phase to protect the manifest file and extracting folder(s). + /// + public const string ModifyInstallationStates = "Global\\DnupFinalize"; + } + } +} diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 50819d3ecad1..2bf453dc288b 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// /// Base record for .NET installation information with common properties. /// -internal record DotnetInstallBase( +public record DotnetInstallBase( string ResolvedDirectory, InstallType Type, InstallMode Mode, @@ -26,12 +26,13 @@ internal record InstallRequestOptions() /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// -internal record DotnetInstall( +public record DotnetInstall( DotnetVersion FullySpecifiedVersion, string MuxerDirectory, InstallType Type, InstallMode Mode, - InstallArchitecture Architecture) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); + InstallArchitecture Architecture, + ManagementCadence Cadence) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. @@ -42,4 +43,5 @@ internal record DotnetInstallRequest( InstallType Type, InstallMode Mode, InstallArchitecture Architecture, + ManagementCadence Cadence, InstallRequestOptions Options) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index e13c3c105fdf..e974b654f965 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// /// Represents the type of .NET version (SDK or Runtime). /// -internal enum DotnetVersionType +public enum DotnetVersionType { /// Automatically detect based on version format. Auto, @@ -25,7 +25,7 @@ internal enum DotnetVersionType /// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. /// [DebuggerDisplay("{Value} ({VersionType})")] -internal readonly record struct DotnetVersion : IComparable, IComparable, IEquatable +public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { private readonly ReleaseVersion? _releaseVersion; diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index afbc38cd1ccc..1b692d8a67a9 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { internal interface IDnupManifest { - IEnumerable GetInstalledVersions(); + IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); void AddInstalledVersion(DotnetInstall version); void RemoveInstalledVersion(DotnetInstall version); } diff --git a/src/Installer/dnup/InstallArchitecture.cs b/src/Installer/dnup/InstallArchitecture.cs index 67dbe11a2156..a046bf3d1721 100644 --- a/src/Installer/dnup/InstallArchitecture.cs +++ b/src/Installer/dnup/InstallArchitecture.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallArchitecture + public enum InstallArchitecture { x86, x64, diff --git a/src/Installer/dnup/InstallMode.cs b/src/Installer/dnup/InstallMode.cs index 14cbfd8e5ab8..329ac4e27416 100644 --- a/src/Installer/dnup/InstallMode.cs +++ b/src/Installer/dnup/InstallMode.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - internal enum InstallMode + public enum InstallMode { SDK, Runtime, diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 639816faa015..40d9910df6db 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -16,7 +16,7 @@ private InstallerOrchestratorSingleton() public static InstallerOrchestratorSingleton Instance => _instance; - private ScopedMutex modifyInstallStateMutex() => new ScopedMutex("Global\\Finalize"); + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); // Returns null on failure, DotnetInstall on success public DotnetInstall? Install(DotnetInstallRequest installRequest) diff --git a/src/Installer/dnup/ManagementCadence.cs b/src/Installer/dnup/ManagementCadence.cs new file mode 100644 index 000000000000..963b5bae8a2b --- /dev/null +++ b/src/Installer/dnup/ManagementCadence.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public enum ManagementCadenceType + { + DNUP, + GlobalJson, + Standalone + } + + public struct ManagementCadence + { + public ManagementCadence() + { + Type = ManagementCadenceType.DNUP; + Metadata = new Dictionary(); + } + public ManagementCadence(ManagementCadenceType managementStyle) : this() + { + Type = managementStyle; + Metadata = []; + } + + public ManagementCadenceType Type { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 72a0288cd655..62e8744a2775 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -33,6 +33,7 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, - dotnetChannelVersion.Architecture); + dotnetChannelVersion.Architecture, + dotnetChannelVersion.Cadence); } } From 1c465ef3ad711f3ea84e86786c8853e869d24c6d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:15:50 -0700 Subject: [PATCH 043/153] Add implementation of manifest methods --- src/Installer/dnup/DnupManifestJsonContext.cs | 12 +++ src/Installer/dnup/DnupSharedManifest.cs | 76 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/Installer/dnup/DnupManifestJsonContext.cs diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs new file mode 100644 index 000000000000..746e70f50d62 --- /dev/null +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(List))] + public partial class DnupManifestJsonContext : JsonSerializerContext { } +} diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 9a9ca36d62f0..254aace0db81 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -1,23 +1,95 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Linq; +using System.IO; +using System.Text.Json; +using System.Threading; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class DnupSharedManifest : IDnupManifest { - public IEnumerable GetInstalledVersions() + private static readonly string ManifestPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "dnup", "dnup_manifest.json"); + + public DnupSharedManifest() + { + EnsureManifestExists(); + } + + private void EnsureManifestExists() + { + if (!File.Exists(ManifestPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, JsonSerializer.Serialize(new List(), DnupManifestJsonContext.Default.ListDotnetInstall)); + } + } + + private void AssertHasFinalizationMutex() { - return []; + var mutex = Mutex.OpenExisting(Constants.MutexNames.ModifyInstallationStates); + if (!mutex.WaitOne(0)) + { + throw new InvalidOperationException("The dnup manifest was accessed while not holding the mutex."); + } + mutex.ReleaseMutex(); + mutex.Dispose(); + } + + public IEnumerable GetInstalledVersions(IInstallationValidator? validator = null) + { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var json = File.ReadAllText(ManifestPath); + try + { + var installs = JsonSerializer.Deserialize(json, DnupManifestJsonContext.Default.ListDotnetInstall); + var validInstalls = installs ?? new List(); + + if (validator != null) + { + var invalids = validInstalls.Where(i => !validator.Validate(i)).ToList(); + if (invalids.Count > 0) + { + validInstalls = validInstalls.Except(invalids).ToList(); + var newJson = JsonSerializer.Serialize(validInstalls, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, newJson); + } + } + return validInstalls; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible: {ex.Message}"); + } } public void AddInstalledVersion(DotnetInstall version) { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.Add(version); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, json); } public void RemoveInstalledVersion(DotnetInstall version) { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.RemoveAll(i => i.Id == version.Id && i.FullySpecifiedVersion == version.FullySpecifiedVersion); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, json); } } From 2da2e773c5d8eb2f7867754b1ec19e172f83657d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:25:12 -0700 Subject: [PATCH 044/153] Extract + Install combined to one step --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 446 +++++++++++++------ 1 file changed, 302 insertions(+), 144 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index a294d6973150..4414f60c61ee 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -16,42 +16,33 @@ internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable private readonly DotnetInstallRequest _request; private readonly DotnetInstall _install; private string scratchDownloadDirectory; - private string scratchExtractionDirectory; + private string? _archivePath; public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall version) { _request = request; _install = version; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; - scratchExtractionDirectory = Directory.CreateTempSubdirectory().FullName; } public void Prepare() { using var releaseManifest = new ReleaseManifest(); var archiveName = $"dotnet-{_install.Id}"; - var archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); Spectre.Console.AnsiConsole.Progress() .Start(ctx => { var downloadTask = ctx.AddTask($"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}"); - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, archivePath, reporter); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, _archivePath, reporter); if (!downloadSuccess) { throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); } downloadTask.Value = 100; - - var extractTask = ctx.AddTask($"Extracting .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); - var extractResult = ExtractArchive(archivePath, scratchExtractionDirectory, extractTask); - if (extractResult != null) - { - throw new InvalidOperationException($"Failed to extract archive: {extractResult}"); - } - extractTask.Value = extractTask.MaxValue; }); } @@ -66,188 +57,355 @@ private void VerifyArchive(string archivePath) } } + + + internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + { + return versionString is null + ? $"dotnet-sdk-{rid}{suffix}" + : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + } + + + + public void Commit() + { + Commit(GetExistingSdkVersions(_request.TargetDirectory)); + } + + public void Commit(IEnumerable existingSdkVersions) + { + if (_archivePath == null || !File.Exists(_archivePath)) + { + throw new InvalidOperationException("Archive not found. Make sure Prepare() was called successfully."); + } + + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var installTask = ctx.AddTask($"Installing .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.TargetDirectory, existingSdkVersions, installTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + installTask.Value = installTask.MaxValue; + }); + } + /** - Extracts the specified archive to the given extraction directory. - The archive will be decompressed if necessary. - Expects either a .tar.gz, .tar, or .zip archive. - */ - private string? ExtractArchive(string archivePath, string extractionDirectory, Spectre.Console.ProgressTask extractTask) + * Extracts the archive directly to the target directory with special handling for muxer. + * Combines extraction and installation into a single operation. + */ + private string? ExtractArchiveDirectlyToTarget(string archivePath, string targetDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? installTask) { try { + // Ensure target directory exists + Directory.CreateDirectory(targetDir); + + var muxerConfig = ConfigureMuxerHandling(existingSdkVersions); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - string decompressedPath = archivePath; - if (needsDecompression) - { - decompressedPath = Path.Combine(Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, "decompressed.tar"); - using FileStream originalFileStream = File.OpenRead(archivePath); - using FileStream decompressedFileStream = File.Create(decompressedPath); - using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); - decompressionStream.CopyTo(decompressedFileStream); - } - // Count files in tar - long totalFiles = 0; - using (var tarStream = File.OpenRead(decompressedPath)) - { - var tarReader = new System.Formats.Tar.TarReader(tarStream); - while (tarReader.GetNextEntry() != null) - { - totalFiles++; - } - } - if (extractTask != null) - { - extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; - using (var tarStream = File.OpenRead(decompressedPath)) - { - var tarReader = new System.Formats.Tar.TarReader(tarStream); - System.Formats.Tar.TarEntry? entry; - while ((entry = tarReader.GetNextEntry()) != null) - { - if (entry.EntryType == System.Formats.Tar.TarEntryType.RegularFile) - { - var outPath = Path.Combine(extractionDirectory, entry.Name); - Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); - using var outStream = File.Create(outPath); - entry.DataStream?.CopyTo(outStream); - } - extractTask.Increment(1); - } - } - } - // Clean up temporary decompressed file - if (needsDecompression && File.Exists(decompressedPath)) - { - File.Delete(decompressedPath); - } + return ExtractTarArchive(archivePath, targetDir, muxerConfig, installTask); } else { - long totalFiles = 0; - using (var zip = ZipFile.OpenRead(archivePath)) - { - totalFiles = zip.Entries.Count; - } - if (extractTask != null) - { - extractTask.MaxValue = totalFiles > 0 ? totalFiles : 1; - using (var zip = ZipFile.OpenRead(archivePath)) - { - foreach (var entry in zip.Entries) - { - var outPath = Path.Combine(extractionDirectory, entry.FullName); - Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); - entry.ExtractToFile(outPath, overwrite: true); - extractTask.Increment(1); - } - } - } + return ExtractZipArchive(archivePath, targetDir, muxerConfig, installTask); } } catch (Exception e) { return e.Message; } - return null; } - internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + /** + * Configure muxer handling by determining if it needs to be updated. + */ + private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) { - return versionString is null - ? $"dotnet-sdk-{rid}{suffix}" - : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; + DotnetVersion newRuntimeVersion = _install.FullySpecifiedVersion; + bool shouldUpdateMuxer = existingMuxerVersion is null || newRuntimeVersion.CompareTo(existingMuxerVersion) > 0; + + string muxerName = DnupUtilities.GetDotnetExeName(); + string muxerTargetPath = Path.Combine(_request.TargetDirectory, muxerName); + + return new MuxerHandlingConfig( + muxerName, + muxerTargetPath, + shouldUpdateMuxer); } - private string? ExtractSdkToDir(string extractedArchivePath, string destDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? commitTask = null, List? files = null) + /** + * Extracts a tar or tar.gz archive to the target directory. + */ + private string? ExtractTarArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) { - // Ensure destination directory exists - Directory.CreateDirectory(destDir); - - DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; - DotnetVersion runtimeVersion = _install.FullySpecifiedVersion; + string decompressedPath = DecompressTarGzIfNeeded(archivePath, out bool needsDecompression); try { - CopyMuxer(existingMuxerVersion, runtimeVersion, extractedArchivePath, destDir); - var fileList = files ?? Directory.EnumerateFileSystemEntries(extractedArchivePath, "*", SearchOption.AllDirectories).ToList(); - int processed = 0; - foreach (var sourcePath in fileList) + // Count files in tar for progress reporting + long totalFiles = CountTarEntries(decompressedPath); + + // Set progress maximum + if (installTask != null) { - var relativePath = Path.GetRelativePath(extractedArchivePath, sourcePath); - var destPath = Path.Combine(destDir, relativePath); + installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + } - if (File.Exists(sourcePath)) - { - // Skip dotnet.exe - if (string.Equals(Path.GetFileName(sourcePath), DnupUtilities.GetDotnetExeName(), StringComparison.OrdinalIgnoreCase)) - { - continue; - } + // Extract files directly to target + ExtractTarContents(decompressedPath, targetDir, muxerConfig, installTask); - Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); - DnupUtilities.ForceReplaceFile(sourcePath, destPath); - } - else if (Directory.Exists(sourcePath)) - { - // Merge directories: create if not exists, do not delete anything in dest - Directory.CreateDirectory(destPath); - } - processed++; - if (commitTask != null) - { - commitTask.Value = processed; - } + return null; + } + finally + { + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); } } - catch (Exception e) + } + + /** + * Decompresses a .tar.gz file if needed, returning the path to the tar file. + */ + private string DecompressTarGzIfNeeded(string archivePath, out bool needsDecompression) + { + needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + if (!needsDecompression) { - return e.Message; + return archivePath; + } + + string decompressedPath = Path.Combine( + Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, + "decompressed.tar"); + + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(decompressedPath); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + + return decompressedPath; + } + + /** + * Counts the number of entries in a tar file for progress reporting. + */ + private long CountTarEntries(string tarPath) + { + long totalFiles = 0; + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + while (tarReader.GetNextEntry() != null) + { + totalFiles++; + } + return totalFiles; + } + + /** + * Extracts the contents of a tar file to the target directory. + */ + private void ExtractTarContents(string tarPath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + TarEntry? entry; + + while ((entry = tarReader.GetNextEntry()) != null) + { + if (entry.EntryType == TarEntryType.RegularFile) + { + ExtractTarFileEntry(entry, targetDir, muxerConfig, installTask); + } + else if (entry.EntryType == TarEntryType.Directory) + { + // Create directory if it doesn't exist + var dirPath = Path.Combine(targetDir, entry.Name); + Directory.CreateDirectory(dirPath); + installTask?.Increment(1); + } + else + { + // Skip other entry types + installTask?.Increment(1); + } } + } + + /** + * Extracts a single file entry from a tar archive. + */ + private void ExtractTarFileEntry(TarEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + var fileName = Path.GetFileName(entry.Name); + var destPath = Path.Combine(targetDir, entry.Name); + + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromTar(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + using var outStream = File.Create(destPath); + entry.DataStream?.CopyTo(outStream); + } + + installTask?.Increment(1); + } + + /** + * Handles updating the muxer from a tar entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromTar(TarEntry entry, string muxerTargetPath) + { + // Create a temporary file for the muxer first to avoid locking issues + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + using (var outStream = File.Create(tempMuxerPath)) + { + entry.DataStream?.CopyTo(outStream); + } + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } + } + + /** + * Extracts a zip archive to the target directory. + */ + private string? ExtractZipArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + long totalFiles = CountZipEntries(archivePath); + + installTask?.MaxValue = totalFiles > 0 ? totalFiles : 1; + + using var zip = ZipFile.OpenRead(archivePath); + foreach (var entry in zip.Entries) + { + ExtractZipEntry(entry, targetDir, muxerConfig, installTask); + } + return null; } - private void CopyMuxer(DotnetVersion? existingMuxerVersion, DotnetVersion newRuntimeVersion, string archiveDir, string destDir) + /** + * Counts the number of entries in a zip file for progress reporting. + */ + private long CountZipEntries(string zipPath) + { + using var zip = ZipFile.OpenRead(zipPath); + return zip.Entries.Count; + } + + /** + * Extracts a single entry from a zip archive. + */ + private void ExtractZipEntry(ZipArchiveEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) { - // The "dotnet" exe (muxer) is special in two ways: - // 1. It is shared between all SDKs, so it may be locked by another process. - // 2. It should always be the newest version, so we don't want to overwrite it if the SDK - // we're installing is older than the one already installed. - var muxerTargetPath = Path.Combine(destDir, DnupUtilities.GetDotnetExeName()); + var fileName = Path.GetFileName(entry.FullName); + var destPath = Path.Combine(targetDir, entry.FullName); - if (existingMuxerVersion is not null && newRuntimeVersion.CompareTo(existingMuxerVersion) <= 0) + // Skip directories (we'll create them for files as needed) + if (string.IsNullOrEmpty(fileName)) { - // The new SDK is older than the existing muxer, so we don't need to do anything. + Directory.CreateDirectory(destPath); + installTask?.Increment(1); return; } - // The new SDK is newer than the existing muxer, so we need to replace it. - DnupUtilities.ForceReplaceFile(Path.Combine(archiveDir, DnupUtilities.GetDotnetExeName()), muxerTargetPath); + // Special handling for dotnet executable (muxer) + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromZip(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + entry.ExtractToFile(destPath, overwrite: true); + } + + installTask?.Increment(1); } - public void Commit() + /** + * Handles updating the muxer from a zip entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromZip(ZipArchiveEntry entry, string muxerTargetPath) { - Commit(GetExistingSdkVersions(_request.TargetDirectory)); + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + entry.ExtractToFile(tempMuxerPath, overwrite: true); + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } } - public void Commit(IEnumerable existingSdkVersions) + /** + * Configuration class for muxer handling. + */ + private readonly struct MuxerHandlingConfig { - Spectre.Console.AnsiConsole.Progress() - .Start(ctx => - { - var files = Directory.EnumerateFileSystemEntries(scratchExtractionDirectory, "*", SearchOption.AllDirectories).ToList(); - var commitTask = ctx.AddTask($"Installing .NET SDK", autoStart: true); - commitTask.MaxValue = files.Count > 0 ? files.Count : 1; - ExtractSdkToDir(scratchExtractionDirectory, _request.TargetDirectory, existingSdkVersions, commitTask, files); - commitTask.Value = commitTask.MaxValue; - }); + public string MuxerName { get; } + public string MuxerTargetPath { get; } + public bool ShouldUpdateMuxer { get; } + + public MuxerHandlingConfig(string muxerName, string muxerTargetPath, bool shouldUpdateMuxer) + { + MuxerName = muxerName; + MuxerTargetPath = muxerTargetPath; + ShouldUpdateMuxer = shouldUpdateMuxer; + } } public void Dispose() { - File.Delete(scratchExtractionDirectory); - File.Delete(scratchDownloadDirectory); + try + { + // Clean up temporary download directory + if (Directory.Exists(scratchDownloadDirectory)) + { + Directory.Delete(scratchDownloadDirectory, recursive: true); + } + } + catch + { + } } // This should be cached and more sophisticated based on vscode logic in the future From b35c6d8d52b01215064f304cc9236697b9d0c171 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 13:42:41 -0700 Subject: [PATCH 045/153] Fix json aot serialization --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- src/Installer/dnup/DnupManifestJsonContext.cs | 9 ++- src/Installer/dnup/DotnetVersion.cs | 4 +- .../dnup/DotnetVersionJsonConverter.cs | 73 +++++++++++++++++++ .../dnup/ManifestChannelVersionResolver.cs | 8 +- 5 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 src/Installer/dnup/DotnetVersionJsonConverter.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 4414f60c61ee..2b9ec5ddc344 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -444,7 +444,7 @@ private IEnumerable GetExistingSdkVersions(string targetDirectory } catch { - return Enumerable.Empty(); + return []; } } } diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 746e70f50d62..2abd4eb49b68 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -6,7 +6,14 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = new[] { typeof(DotnetVersionJsonConverter) })] [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(DotnetVersion))] + [JsonSerializable(typeof(DotnetVersionType))] + [JsonSerializable(typeof(InstallMode))] + [JsonSerializable(typeof(InstallArchitecture))] + [JsonSerializable(typeof(InstallType))] + [JsonSerializable(typeof(ManagementCadence))] public partial class DnupManifestJsonContext : JsonSerializerContext { } } diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs index e974b654f965..cdc6cb864021 100644 --- a/src/Installer/dnup/DotnetVersion.cs +++ b/src/Installer/dnup/DotnetVersion.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Text.Json.Serialization; using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -25,6 +26,7 @@ public enum DotnetVersionType /// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. /// [DebuggerDisplay("{Value} ({VersionType})")] +[JsonConverter(typeof(DotnetVersionJsonConverter))] public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable { private readonly ReleaseVersion? _releaseVersion; @@ -88,7 +90,7 @@ public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersio { Value = value ?? string.Empty; VersionType = versionType; - _releaseVersion = ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; + _releaseVersion = !string.IsNullOrEmpty(Value) && ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; } /// diff --git a/src/Installer/dnup/DotnetVersionJsonConverter.cs b/src/Installer/dnup/DotnetVersionJsonConverter.cs new file mode 100644 index 000000000000..4ff20013e0c1 --- /dev/null +++ b/src/Installer/dnup/DotnetVersionJsonConverter.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + /// + /// A custom JSON converter for the DotnetVersion struct. + /// This ensures proper serialization and deserialization of the struct. + /// + public class DotnetVersionJsonConverter : JsonConverter + { + /// + /// Reads and converts the JSON to a DotnetVersion struct. + /// + public override DotnetVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + string? versionString = reader.GetString(); + return new DotnetVersion(versionString); + } + else if (reader.TokenType == JsonTokenType.StartObject) + { + string? versionString = null; + DotnetVersionType versionType = DotnetVersionType.Auto; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the property value + + if (propertyName != null && propertyName.Equals("value", StringComparison.OrdinalIgnoreCase)) + { + versionString = reader.GetString(); + } + else if (propertyName != null && propertyName.Equals("versionType", StringComparison.OrdinalIgnoreCase)) + { + versionType = (DotnetVersionType)reader.GetInt32(); + } + } + } + + return new DotnetVersion(versionString, versionType); + } + else if (reader.TokenType == JsonTokenType.Null) + { + return new DotnetVersion(null); + } + + throw new JsonException($"Unexpected token {reader.TokenType} when deserializing DotnetVersion"); + } + + /// + /// Writes a DotnetVersion struct as JSON. + /// + public override void Write(Utf8JsonWriter writer, DotnetVersion value, JsonSerializerOptions options) + { + if (string.IsNullOrEmpty(value.Value)) + { + writer.WriteNullValue(); + return; + } + writer.WriteStringValue(value.Value); + + } + } +} diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 62e8744a2775..22502a23c1ff 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -17,9 +17,9 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) // Resolve strings or other options if (!dotnetVersion.IsValidMajorVersion()) { - // TODO ping the r-manifest to handle 'lts' 'latest' etc - // Do this in a separate class and use dotnet release library to do so - // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases + // TODO ping the r-manifest to handle 'lts' 'latest' etc + // Do this in a separate class and use dotnet release library to do so + // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases } // Make sure the version is fully specified @@ -29,7 +29,7 @@ public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) } return new DotnetInstall( - fullySpecifiedVersion, + dotnetVersion, dotnetChannelVersion.ResolvedDirectory, dotnetChannelVersion.Type, dotnetChannelVersion.Mode, From 7dfe03e9b3a638d32463ea8d4e1d43c40d156cda Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:12:41 -0700 Subject: [PATCH 046/153] find existing versions + parse channels --- src/Installer/dnup/DnupSharedManifest.cs | 15 ++++++ src/Installer/dnup/IDnupManifest.cs | 1 + .../dnup/ManifestChannelVersionResolver.cs | 23 ++++----- src/Installer/dnup/ReleaseManifest.cs | 49 +++++++++++++++++++ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 254aace0db81..863eb18a92e5 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -70,6 +70,21 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v } } + /// + /// Gets installed versions filtered by a specific muxer directory. + /// + /// Directory to filter by (must match the MuxerDirectory property) + /// Optional validator to check installation validity + /// Installations that match the specified directory + public IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null) + { + return GetInstalledVersions(validator) + .Where(install => string.Equals( + Path.GetFullPath(install.MuxerDirectory), + Path.GetFullPath(muxerDirectory), + StringComparison.OrdinalIgnoreCase)); + } + public void AddInstalledVersion(DotnetInstall version) { AssertHasFinalizationMutex(); diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 1b692d8a67a9..252eefd41e65 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -8,6 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper internal interface IDnupManifest { IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); + IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null); void AddInstalledVersion(DotnetInstall version); void RemoveInstalledVersion(DotnetInstall version); } diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 22502a23c1ff..2b3c89aff8a2 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -10,22 +11,18 @@ internal class ManifestChannelVersionResolver { public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) { - string fullySpecifiedVersion = dotnetChannelVersion.ChannelVersion; + string channel = dotnetChannelVersion.ChannelVersion; + DotnetVersion dotnetVersion = new DotnetVersion(channel); - DotnetVersion dotnetVersion = new DotnetVersion(fullySpecifiedVersion); - - // Resolve strings or other options - if (!dotnetVersion.IsValidMajorVersion()) - { - // TODO ping the r-manifest to handle 'lts' 'latest' etc - // Do this in a separate class and use dotnet release library to do so - // https://github.com/dotnet/deployment-tools/tree/main/src/Microsoft.Deployment.DotNet.Releases - } - - // Make sure the version is fully specified + // If not fully specified, resolve to latest using ReleaseManifest if (!dotnetVersion.IsFullySpecified) { - // TODO ping the r-manifest to resolve latest within the specified qualities + var manifest = new ReleaseManifest(); + var latestVersion = manifest.GetLatestVersionForChannel(channel, dotnetChannelVersion.Mode); + if (latestVersion != null) + { + dotnetVersion = new DotnetVersion(latestVersion); + } } return new DotnetInstall( diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 0e186df0db65..644ad49c64dc 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -19,6 +19,55 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// internal class ReleaseManifest : IDisposable { + /// + /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// + /// Channel string (e.g., "9", "9.0", "9.0.1xx") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public string? GetLatestVersionForChannel(string channel, InstallMode mode) + { + var products = GetProductCollection(); + // Parse channel + var parts = channel.Split('.'); + int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; + int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; + string? featureBandPattern = null; + if (parts.Length == 3 && parts[2].EndsWith("xx")) + { + featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" + } + + // Find matching product(s) + var matchingProducts = products.Where(p => + int.TryParse(p.ProductVersion.Split('.')[0], out var prodMajor) && prodMajor == major && + (minor == -1 || (p.ProductVersion.Split('.').Length > 1 && int.TryParse(p.ProductVersion.Split('.')[1], out var prodMinor) && prodMinor == minor)) + ); + foreach (var product in matchingProducts.OrderByDescending(p => p.ProductVersion)) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + // Filter by mode (SDK or Runtime) + var filtered = releases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ); + + // If feature band pattern is specified, filter SDK releases by patch + if (featureBandPattern != null && mode == InstallMode.SDK) + { + filtered = filtered.Where(r => + r.Version.Patch >= 100 && r.Version.Patch <= 999 && + r.Version.Patch.ToString().StartsWith(featureBandPattern) + ); + } + + var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); + if (latest != null) + { + return latest.Version.ToString(); + } + } + return null; + } private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; From e4f4fd06fb1bcb85daab59bdb2468d97325abfea Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:13:05 -0700 Subject: [PATCH 047/153] find existing versions --- .../dnup/InstallerOrchestratorSingleton.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 40d9910df6db..0384e885e0c5 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -30,6 +32,7 @@ private InstallerOrchestratorSingleton() { if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) { + Console.WriteLine($"\n.NET SDK {install.FullySpecifiedVersion.Value} is already installed, skipping installation."); return install; } } @@ -62,16 +65,27 @@ private InstallerOrchestratorSingleton() return install; } - // Add a doc string mentioning you must hold a mutex over the directory + /// + /// Gets the existing installs from the manifest. Must hold a mutex over the directory. + /// private IEnumerable GetExistingInstalls(string directory) { - // assert we have the finalize lock - return Enumerable.Empty(); + var manifestManager = new DnupSharedManifest(); + // Use the overload that filters by muxer directory + return manifestManager.GetInstalledVersions(directory); } + /// + /// Checks if the installation already exists. Must hold a mutex over the directory. + /// private bool InstallAlreadyExists(string directory, DotnetInstall install) { - // assert we have the finalize lock - return false; + var existingInstalls = GetExistingInstalls(directory); + + // Check if there's any existing installation that matches the version we're trying to install + return existingInstalls.Any(existing => + existing.FullySpecifiedVersion.Value == install.FullySpecifiedVersion.Value && + existing.Type == install.Type && + existing.Architecture == install.Architecture); } } From dca4da2c552771b702d3978e7c7e8dd4b4321def Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 14:56:13 -0700 Subject: [PATCH 048/153] Add tests for version parsing. --- src/Installer/dnup/ReleaseManifest.cs | 156 +++++++++++++----------- src/Installer/dnup/dnup.code-workspace | 130 +++++++++++--------- test/dnup.Tests/DotnetInstallTests.cs | 6 +- test/dnup.Tests/ReleaseManifestTests.cs | 39 ++++++ 4 files changed, 200 insertions(+), 131 deletions(-) create mode 100644 test/dnup.Tests/ReleaseManifestTests.cs diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 644ad49c64dc..189a0542e0a0 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -27,7 +27,6 @@ internal class ReleaseManifest : IDisposable /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) { - var products = GetProductCollection(); // Parse channel var parts = channel.Split('.'); int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; @@ -38,36 +37,98 @@ internal class ReleaseManifest : IDisposable featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" } - // Find matching product(s) - var matchingProducts = products.Where(p => - int.TryParse(p.ProductVersion.Split('.')[0], out var prodMajor) && prodMajor == major && - (minor == -1 || (p.ProductVersion.Split('.').Length > 1 && int.TryParse(p.ProductVersion.Split('.')[1], out var prodMinor) && prodMinor == minor)) - ); - foreach (var product in matchingProducts.OrderByDescending(p => p.ProductVersion)) + // Load the index manifest + var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + // For major-only channels like "9", we need to find all products with that major version + if (minor == -1) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - // Filter by mode (SDK or Runtime) - var filtered = releases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ); - - // If feature band pattern is specified, filter SDK releases by patch - if (featureBandPattern != null && mode == InstallMode.SDK) + // Get all products with matching major version + var matchingProducts = index.Where(p => + { + // Split the product version into parts + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) + { + return productMajor == major; + } + return false; + }).ToList(); + + // For each matching product, get releases and filter + var allReleases = new List<(ProductRelease Release, Product Product)>(); + + foreach (var matchingProduct in matchingProducts) { - filtered = filtered.Where(r => - r.Version.Patch >= 100 && r.Version.Patch <= 999 && - r.Version.Patch.ToString().StartsWith(featureBandPattern) - ); + var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter by mode (SDK or Runtime) + var filteredForProduct = productReleases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? + f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ).ToList(); + + foreach (var release in filteredForProduct) + { + allReleases.Add((release, matchingProduct)); + } } - var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); - if (latest != null) + // Find the latest release across all products + var latestAcrossProducts = allReleases.OrderByDescending(r => r.Release.Version).FirstOrDefault(); + + if (latestAcrossProducts.Release != null) { - return latest.Version.ToString(); + return latestAcrossProducts.Release.Version.ToString(); } + + return null; } + + // Find the product for the requested major.minor + string channelKey = $"{major}.{minor}"; + var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + if (product == null) + { + return null; + } + + // Load releases from the sub-manifest for this product + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter by mode (SDK or Runtime) + var filtered = releases.Where(r => + r.Files.Any(f => mode == InstallMode.SDK ? + f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) + ).ToList(); + + // If feature band pattern is specified, handle it specially for SDK + if (featureBandPattern != null && mode == InstallMode.SDK) + { + if (int.TryParse(featureBandPattern, out var bandNum)) + { + // For feature bands, we need to construct the version manually + // Since SDK feature bands are represented differently than runtime versions, + // we return a special format for feature bands + if (filtered.Any()) + { + // Return the feature band version pattern + return $"{major}.{minor}.{featureBandPattern}00"; + } + } + } + + var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); + if (latest != null) + { + return latest.Version.ToString(); + } + return null; } + private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; @@ -344,60 +405,12 @@ private ProductCollection GetProductCollection() // Use ScopedMutex for cross-process locking using var mutex = new ScopedMutex(ReleaseCacheMutexName); - if (!mutex.HasHandle) - { - // If we couldn't acquire the mutex, still try to load the collection - // but don't write to the cache file to avoid conflicts - return _productCollection ??= ProductCollection.GetAsync().GetAwaiter().GetResult(); - } - - // Double-check locking pattern - if (_productCollection != null) - { - return _productCollection; - } - - string cacheFilePath = Path.Combine(_cacheDirectory, "releases.json"); - bool useCachedData = false; - - if (File.Exists(cacheFilePath)) - { - var cacheFileAge = File.GetLastWriteTimeUtc(cacheFilePath); - // If cache exists and is less than 24 hours old, use it - useCachedData = (DateTime.UtcNow - cacheFileAge).TotalHours < 24; - } - - if (useCachedData) - { - try - { - string json = File.ReadAllText(cacheFilePath); - _productCollection = DeserializeProductCollection(json); - return _productCollection; - } - catch - { - // Continue to fetch fresh data if cache loading fails - } - } - - // Fetch fresh data with retry logic + // Always use the index manifest for ProductCollection for (int attempt = 1; attempt <= MaxRetryCount; attempt++) { try { _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); - - try - { - string json = SerializeProductCollection(_productCollection); - File.WriteAllText(cacheFilePath, json); - } - catch - { - // Continue since we have the data in memory - } - return _productCollection; } catch @@ -406,7 +419,6 @@ private ProductCollection GetProductCollection() { throw; } - Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff } } diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace index fca5689a68dc..e0349b05a271 100644 --- a/src/Installer/dnup/dnup.code-workspace +++ b/src/Installer/dnup/dnup.code-workspace @@ -1,60 +1,78 @@ { - "folders": [ - { - "path": ".", - "name": "dnup" - } - ], - "settings": { - "dotnet.defaultSolution": "dnup.csproj", - "omnisharp.defaultLaunchSolution": "dnup.csproj", - "csharp.debug.console": "externalTerminal", - "editor.formatOnSave": true, - "omnisharp.enableRoslynAnalyzers": true, - "omnisharp.useModernNet": true - }, - "launch": { - "version": "0.2.0", - "configurations": [ - { - "name": "Launch dnup (Default)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", - "args": [ - "sdk", - "install" - ], - "cwd": "${workspaceFolder}", - "console": "externalTerminal", - "stopAtEntry": false, - "logging": { - "moduleLoad": false + "folders": [ + { + "path": ".", + "name": "dnup" + }, + { + "path": "../../../../test/dnup.Tests", + "name": "dnup.Tests" } - } ], - "compounds": [] - }, - "tasks": { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "${workspaceFolder}/dnup.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" + "settings": { + "dotnet.defaultSolution": "dnup.csproj", + "omnisharp.defaultLaunchSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder}", + "console": "externalTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + } ], - "problemMatcher": "$msCompile", - "group": { - "kind": "build", - "isDefault": true - } - } - ] - } -} + "compounds": [] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "type": "process", + "command": "dotnet", + "args": [ + "test", + "../../../../test/dnup.Tests/dnup.Tests.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + } + ] + } +} \ No newline at end of file diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs index 68f70ad57949..b0c497b8e9a1 100644 --- a/test/dnup.Tests/DotnetInstallTests.cs +++ b/test/dnup.Tests/DotnetInstallTests.cs @@ -33,7 +33,7 @@ public void DotnetInstall_ShouldInheritFromBase() var mode = InstallMode.SDK; var architecture = InstallArchitecture.x64; - var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture); + var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture, new ManagementCadence()); install.FullySpecifiedVersion.Value.Should().Be(version); install.ResolvedDirectory.Should().Be(directory); @@ -58,8 +58,8 @@ public void MultipleInstances_ShouldHaveUniqueIds() public void Records_ShouldSupportValueEquality() { // Arrange - var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); - var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); + var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); + var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); // Act & Assert // Records should be equal based on values, except for the Id which is always unique diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs new file mode 100644 index 000000000000..3484a9a38ba1 --- /dev/null +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -0,0 +1,39 @@ +using System; +using Xunit; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests +{ + public class ReleaseManifestTests + { + [Fact] + public void GetLatestVersionForChannel_MajorOnly_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("9", InstallMode.SDK); + Assert.True(!string.IsNullOrEmpty(version)); + } + + [Fact] + public void GetLatestVersionForChannel_MajorMinor_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("9.0", InstallMode.SDK); + Assert.False(string.IsNullOrEmpty(version)); + Assert.StartsWith("9.0.", version); + } + + [Fact] + public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + + var version = manifest.GetLatestVersionForChannel("9.0.1xx", InstallMode.SDK); + Console.WriteLine($"Version found: {version ?? "null"}"); + + // Feature band version should be returned in the format 9.0.100 + Assert.True(!string.IsNullOrEmpty(version)); + Assert.Matches(@"^9\.0\.1\d{2}$", version); + } + } +} From 87eea7db0001b745a7adbed2361838c59ff5c595 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 15:58:50 -0700 Subject: [PATCH 049/153] Correctly parse 9.0.1xx, and 9, and 9.0 --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 52 +- src/Installer/dnup/DotnetInstall.cs | 4 +- src/Installer/dnup/ReleaseManifest.cs | 446 +++++++++++++++--- 4 files changed, 435 insertions(+), 69 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 2b9ec5ddc344..4057de47730b 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -51,7 +51,7 @@ public void Prepare() */ private void VerifyArchive(string archivePath) { - if (!File.Exists(archivePath)) // replace this with actual verification logic once its implemented. + if (!File.Exists(archivePath)) // Enhancement: replace this with actual verification logic once its implemented. { throw new InvalidOperationException("Archive verification failed."); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 8e02dfb8c9ed..d26da67167ad 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -9,6 +9,7 @@ using SpectreAnsiConsole = Spectre.Console.AnsiConsole; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; @@ -22,6 +23,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); + private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); public override int Execute() { @@ -185,7 +187,18 @@ public override int Execute() List additionalVersionsToInstall = new(); - var resolvedChannelVersion = _releaseInfoProvider.GetLatestVersion(resolvedChannel); + // Create a request and resolve it using the channel version resolver + var installRequest = new DotnetInstallRequest( + resolvedChannel, + resolvedInstallPath, + InstallType.User, + InstallMode.SDK, + DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), + new InstallRequestOptions()); + + var resolvedInstall = _channelVersionResolver.Resolve(installRequest); + var resolvedChannelVersion = resolvedInstall.FullySpecifiedVersion.Value; if (resolvedSetDefaultInstall == true && defaultInstallState == InstallType.Admin) { @@ -214,7 +227,42 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - _dotnetInstaller.InstallSdks(resolvedInstallPath, SpectreAnsiConsole.Progress().Start(ctx => ctx), new[] { resolvedChannelVersion }.Concat(additionalVersionsToInstall)); + // Create and use a progress context + var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); + + // Install the main SDK using the InstallerOrchestratorSingleton directly + DotnetInstall? mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); + if (mainInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedChannelVersion}[/]"); + return 1; + } + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.FullySpecifiedVersion}, available via {mainInstall.MuxerDirectory}[/]"); + + // Install any additional versions + foreach (var additionalVersion in additionalVersionsToInstall) + { + // Create the request for the additional version + var additionalRequest = new DotnetInstallRequest( + additionalVersion, + resolvedInstallPath, + InstallType.User, + InstallMode.SDK, + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), + new ManagementCadence(ManagementCadenceType.DNUP), + new InstallRequestOptions()); + + // Install the additional version directly using InstallerOrchestratorSingleton + DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); + if (additionalInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install additional .NET SDK {additionalVersion}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.FullySpecifiedVersion}, available via {additionalInstall.MuxerDirectory}[/]"); + } + } if (resolvedSetDefaultInstall == true) { diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 2bf453dc288b..7ff4b756f224 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -17,7 +17,7 @@ public record DotnetInstallBase( public Guid Id { get; } = Guid.NewGuid(); } -internal record InstallRequestOptions() +public record InstallRequestOptions() { // Include things such as the custom feed here. } @@ -37,7 +37,7 @@ public record DotnetInstall( /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// -internal record DotnetInstallRequest( +public record DotnetInstallRequest( string ChannelVersion, string TargetDirectory, InstallType Type, diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 189a0542e0a0..568d5427dadb 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -33,62 +33,256 @@ internal class ReleaseManifest : IDisposable int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; string? featureBandPattern = null; if (parts.Length == 3 && parts[2].EndsWith("xx")) + // Check if we have a feature band (like 1xx) or a fully specified patch { - featureBandPattern = parts[2].Substring(0, parts[2].Length - 2); // e.g., "1" from "1xx" + featureBand = parts[2].Substring(0, parts[2].Length - 2); + } + else if (int.TryParse(parts[2], out _)) + { + // Fully specified version (e.g., "9.0.103") + isFullySpecified = true; + } } - // Load the index manifest - var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return (major, minor, featureBand, isFullySpecified); + } - // For major-only channels like "9", we need to find all products with that major version - if (minor == -1) + /// + /// Gets products from the index that match the specified major version. + /// + /// The product collection to search + /// The major version to match + /// List of matching products, ordered by minor version (descending) + private List GetProductsForMajorVersion(ProductCollection index, int major) + { + var matchingProducts = index.Where(p => { - // Get all products with matching major version - var matchingProducts = index.Where(p => + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) { // Split the product version into parts var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) - { - return productMajor == major; } return false; }).ToList(); - // For each matching product, get releases and filter - var allReleases = new List<(ProductRelease Release, Product Product)>(); + return productMajor == major; + } + return false; + }).ToList(); foreach (var matchingProduct in matchingProducts) + { + var productParts = p.ProductVersion.Split('.'); { var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + /// + /// List of releases to search + /// Optional major version filter + /// Optional minor version filter + /// Latest SDK version string, or null if none found + private string? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) + { + var allSdks = releases + .SelectMany(r => r.Sdks) + .Where(sdk => + (!majorFilter.HasValue || sdk.Version.Major == majorFilter.Value) && + (!minorFilter.HasValue || sdk.Version.Minor == minorFilter.Value)) + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (allSdks.Any()) + { + return allSdks.First().Version.ToString(); + } - // Filter by mode (SDK or Runtime) - var filteredForProduct = productReleases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? - f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : - f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ).ToList(); + return null; + } - foreach (var release in filteredForProduct) - { - allReleases.Add((release, matchingProduct)); - } - } + /// + /// Gets all runtime components from the releases and returns the latest one. + /// + /// List of releases to search + /// Optional major version filter + /// Optional minor version filter + /// Optional runtime type filter (null for any runtime) + /// Latest runtime version string, or null if none found + private string? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) + { + var allRuntimes = releases.SelectMany(r => r.Runtimes).ToList(); + + // Filter by version constraints if provided + if (majorFilter.HasValue) + { + allRuntimes = allRuntimes.Where(r => r.Version.Major == majorFilter.Value).ToList(); + } - // Find the latest release across all products - var latestAcrossProducts = allReleases.OrderByDescending(r => r.Release.Version).FirstOrDefault(); + if (minorFilter.HasValue) + { + allRuntimes = allRuntimes.Where(r => r.Version.Minor == minorFilter.Value).ToList(); + } - if (latestAcrossProducts.Release != null) + // Filter by runtime type if specified + if (!string.IsNullOrEmpty(runtimeType)) + { + if (string.Equals(runtimeType, "aspnetcore", StringComparison.OrdinalIgnoreCase)) + { + allRuntimes = allRuntimes + .Where(r => r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + else if (string.Equals(runtimeType, "windowsdesktop", StringComparison.OrdinalIgnoreCase)) { - return latestAcrossProducts.Release.Version.ToString(); + allRuntimes = allRuntimes + .Where(r => r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) + .ToList(); } + else // Regular runtime + { + allRuntimes = allRuntimes + .Where(r => !r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase) && + !r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + } + if (allRuntimes.Any()) + { + return allRuntimes.OrderByDescending(r => r.Version).First().Version.ToString(); + } + + return null; + } + + /// + /// Gets the latest SDK version that matches a specific feature band pattern. + /// + /// List of releases to search + /// Major version + /// Minor version + /// Feature band prefix (e.g., "1" for "1xx") + /// Latest matching version string, or fallback format if none found + private string? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) + { + var allSdkComponents = releases.SelectMany(r => r.Sdks).ToList(); + + // Filter by feature band + var featureBandSdks = allSdkComponents + .Where(sdk => + { + var version = sdk.Version.ToString(); + var versionParts = version.Split('.'); + if (versionParts.Length < 3) return false; + + var patchPart = versionParts[2].Split('-')[0]; // Remove prerelease suffix + return patchPart.Length >= 3 && patchPart.StartsWith(featureBand); + }) + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (featureBandSdks.Any()) + { + // Return the exact version from the latest matching SDK + return featureBandSdks.First().Version.ToString(); + } + + // Fallback if no actual release matches the feature band pattern + return $"{major}.{minor}.{featureBand}00"; + } + + /// + /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public string? GetLatestVersionForChannel(string channel, InstallMode mode) + { + // If channel is null or empty, return null + if (string.IsNullOrEmpty(channel)) + { + return null; + } + + // Parse the channel string into components + var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); + + // If major is invalid, return null + if (major < 0) + { + return null; + } + + // If the version is already fully specified, just return it as-is + if (isFullySpecified) + { + return channel; + } + + // Load the index manifest + var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + + // Case 1: Major only version (e.g., "9") + if (minor < 0) + { + return GetLatestVersionForMajorOnly(index, major, mode); + } + + // Case 2: Major.Minor version (e.g., "9.0") + if (minor >= 0 && featureBand == null) + { + return GetLatestVersionForMajorMinor(index, major, minor, mode); + } + + // Case 3: Feature band version (e.g., "9.0.1xx") + if (minor >= 0 && featureBand != null) + { + return GetLatestVersionForFeatureBand(index, major, minor, featureBand, mode); + } + + return null; + } + + /// + /// Gets the latest version for a major-only channel (e.g., "9"). + /// + private string? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallMode mode) + { + // Get products matching the major version + var matchingProducts = GetProductsForMajorVersion(index, major); + + if (!matchingProducts.Any()) + { return null; } + // Get all releases from all matching products + var allReleases = new List(); + foreach (var matchingProduct in matchingProducts) + { + allReleases.AddRange(matchingProduct.GetReleasesAsync().GetAwaiter().GetResult()); + } + + // Find the latest version based on mode + if (mode == InstallMode.SDK) + { + return GetLatestSdkVersion(allReleases, major); + } + else // Runtime mode + { + return GetLatestRuntimeVersion(allReleases, major); + } + } + + /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// + private string? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallMode mode) + { // Find the product for the requested major.minor string channelKey = $"{major}.{minor}"; var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + if (product == null) { return null; @@ -97,38 +291,45 @@ internal class ReleaseManifest : IDisposable // Load releases from the sub-manifest for this product var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - // Filter by mode (SDK or Runtime) - var filtered = releases.Where(r => - r.Files.Any(f => mode == InstallMode.SDK ? - f.Name.Contains("sdk", StringComparison.OrdinalIgnoreCase) : - f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase)) - ).ToList(); - - // If feature band pattern is specified, handle it specially for SDK - if (featureBandPattern != null && mode == InstallMode.SDK) + // Find the latest version based on mode + if (mode == InstallMode.SDK) { - if (int.TryParse(featureBandPattern, out var bandNum)) - { - // For feature bands, we need to construct the version manually - // Since SDK feature bands are represented differently than runtime versions, - // we return a special format for feature bands - if (filtered.Any()) - { - // Return the feature band version pattern - return $"{major}.{minor}.{featureBandPattern}00"; - } - } + return GetLatestSdkVersion(releases, major, minor); } + else // Runtime mode + { + return GetLatestRuntimeVersion(releases, major, minor); + } + } - var latest = filtered.OrderByDescending(r => r.Version).FirstOrDefault(); - if (latest != null) + /// + /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). + /// + private string? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallMode mode) + { + // Find the product for the requested major.minor + string channelKey = $"{major}.{minor}"; + var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); + + if (product == null) { - return latest.Version.ToString(); + return null; } - return null; + // Load releases from the sub-manifest for this product + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // For SDK mode, use feature band filtering + if (mode == InstallMode.SDK) + { + return GetLatestFeatureBandVersion(releases, major, minor, featureBand); + } + else // For Runtime mode, just use regular major.minor filtering + { + return GetLatestRuntimeVersion(releases, major, minor); + } } - + private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; @@ -378,12 +579,8 @@ public bool DownloadArchiveWithVerification(DotnetInstall install, string destin try { var productCollection = GetProductCollection(); - var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value); - if (product == null) return null; - - var release = FindRelease(product, install.FullySpecifiedVersion.Value); - if (release == null) return null; - + var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value) ?? throw new InvalidOperationException($"No product found for version {install.FullySpecifiedVersion.MajorMinor}"); + var release = FindRelease(product, install.FullySpecifiedVersion.Value, install.Mode) ?? throw new InvalidOperationException($"No release found for version {install.FullySpecifiedVersion.Value}"); return FindMatchingFile(release, install); } catch (Exception ex) @@ -465,11 +662,95 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the specific release for the given version. /// - private static ProductRelease? FindRelease(Product product, string version) + private static ProductRelease? FindRelease(Product product, string version, InstallMode mode) { var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); var targetReleaseVersion = new ReleaseVersion(version); - return releases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + + // Get all releases + var allReleases = releases.ToList(); + + // First try to find the exact version in the original release list + var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + if (exactReleaseMatch != null) + { + return exactReleaseMatch; + } + + // Now check through the releases to find matching components + foreach (var release in allReleases) + { + bool foundMatch = false; + + // Check the appropriate collection based on the mode + if (mode == InstallMode.SDK) + { + foreach (var sdk in release.Sdks) + { + // Check for exact match + if (sdk.Version.Equals(targetReleaseVersion)) + { + foundMatch = true; + break; + } + + // Check for match on major, minor, patch + if (sdk.Version.Major == targetReleaseVersion.Major && + sdk.Version.Minor == targetReleaseVersion.Minor && + sdk.Version.Patch == targetReleaseVersion.Patch) + { + foundMatch = true; + break; + } + } + } + else // Runtime mode + { + // Filter by runtime type based on file names in the release + var runtimeTypeMatches = release.Files.Any(f => + f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase) && + !f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase) && + !f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); + + var aspnetCoreMatches = release.Files.Any(f => + f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase)); + + var windowsDesktopMatches = release.Files.Any(f => + f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); + + // Get the appropriate runtime components based on the file patterns + var filteredRuntimes = release.Runtimes; + + // Use the type information from the file names to filter runtime components + // This will prioritize matching the exact runtime type the user is looking for + + foreach (var runtime in filteredRuntimes) + { + // Check for exact match + if (runtime.Version.Equals(targetReleaseVersion)) + { + foundMatch = true; + break; + } + + // Check for match on major, minor, patch + if (runtime.Version.Major == targetReleaseVersion.Major && + runtime.Version.Minor == targetReleaseVersion.Minor && + runtime.Version.Patch == targetReleaseVersion.Patch) + { + foundMatch = true; + break; + } + } + } + + if (foundMatch) + { + return release; + } + } + + return null; } /// @@ -479,12 +760,49 @@ private static ProductCollection DeserializeProductCollection(string json) { var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); - var componentType = install.Mode == InstallMode.SDK ? "sdk" : "runtime"; - return release.Files + // Determine the component type pattern to look for in file names + string componentTypePattern; + if (install.Mode == InstallMode.SDK) + { + componentTypePattern = "sdk"; + } + else // Runtime mode + { + // Determine the specific runtime type based on the release's file patterns + // Default to "runtime" if can't determine more specifically + componentTypePattern = "runtime"; + + // Check if this is specifically an ASP.NET Core runtime + if (install.FullySpecifiedVersion.Value.Contains("aspnetcore")) + { + componentTypePattern = "aspnetcore"; + } + // Check if this is specifically a Windows Desktop runtime + else if (install.FullySpecifiedVersion.Value.Contains("windowsdesktop")) + { + componentTypePattern = "windowsdesktop"; + } + } + + // Filter files based on runtime identifier, component type, and file extension + var matchingFiles = release.Files .Where(f => f.Rid == rid) - .Where(f => f.Name.Contains(componentType, StringComparison.OrdinalIgnoreCase)) - .FirstOrDefault(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)); + .Where(f => f.Name.Contains(componentTypePattern, StringComparison.OrdinalIgnoreCase)) + .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingFiles.Count == 0) + { + return null; + } + + // If we have multiple matching files, prefer the one with the full version in the name + var versionString = install.FullySpecifiedVersion.Value; + var bestMatch = matchingFiles.FirstOrDefault(f => f.Name.Contains(versionString, StringComparison.OrdinalIgnoreCase)); + + // If no file has the exact version string, return the first match + return bestMatch ?? matchingFiles.First(); } /// From 112115288116122fb7062c15db31f10ad6fb6890 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 15:59:11 -0700 Subject: [PATCH 050/153] Fix version parsing --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 19 ++++++++-- src/Installer/dnup/ReleaseManifest.cs | 39 ++++++++++++-------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 4057de47730b..71f9a9e4d7a2 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -61,9 +61,22 @@ private void VerifyArchive(string archivePath) internal static string ConstructArchiveName(string? versionString, string rid, string suffix) { - return versionString is null - ? $"dotnet-sdk-{rid}{suffix}" - : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + // If version is not specified, use a generic name + if (string.IsNullOrEmpty(versionString)) + { + return $"dotnet-sdk-{rid}{suffix}"; + } + + // Make sure the version string doesn't have any build hash or prerelease identifiers + // This ensures compatibility with the official download URLs + string cleanVersion = versionString; + int dashIndex = versionString.IndexOf('-'); + if (dashIndex >= 0) + { + cleanVersion = versionString.Substring(0, dashIndex); + } + + return $"dotnet-sdk-{cleanVersion}-{rid}{suffix}"; } diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 568d5427dadb..5ff89ea4614b 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -20,21 +20,25 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ReleaseManifest : IDisposable { /// - /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// Parses a version channel string into its components. /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx") - /// InstallMode.SDK or InstallMode.Runtime - /// Latest fully specified version string, or null if not found - public string? GetLatestVersionForChannel(string channel, InstallMode mode) + /// Channel string to parse (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// Tuple containing (major, minor, featureBand, isFullySpecified) + private (int Major, int Minor, string? FeatureBand, bool IsFullySpecified) ParseVersionChannel(string channel) { - // Parse channel var parts = channel.Split('.'); int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; - string? featureBandPattern = null; - if (parts.Length == 3 && parts[2].EndsWith("xx")) + // Check if we have a feature band (like 1xx) or a fully specified patch + string? featureBand = null; + bool isFullySpecified = false; + + if (parts.Length >= 3) { + if (parts[2].EndsWith("xx")) + { + // Feature band pattern (e.g., "1xx") featureBand = parts[2].Substring(0, parts[2].Length - 2); } else if (int.TryParse(parts[2], out _)) @@ -60,22 +64,25 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var productParts = p.ProductVersion.Split('.'); if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) { - // Split the product version into parts - var productParts = p.ProductVersion.Split('.'); - } - return false; - }).ToList(); - return productMajor == major; } return false; }).ToList(); - foreach (var matchingProduct in matchingProducts) + // Order by minor version (descending) to prioritize newer versions + return matchingProducts.OrderByDescending(p => { var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 1 && int.TryParse(productParts[1], out var productMinor)) { - var productReleases = matchingProduct.GetReleasesAsync().GetAwaiter().GetResult(); + return productMinor; + } + return 0; + }).ToList(); + } + + /// + /// Gets all SDK components from the releases and returns the latest one. /// /// List of releases to search /// Optional major version filter From f65bc4ec435c01a28669cacea90036926c70efdd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 16:19:39 -0700 Subject: [PATCH 051/153] add lts sts schannel support. fix project. --- src/Installer/dnup/ReleaseManifest.cs | 103 +++++++++++++++++++++++- src/Installer/dnup/dnup.code-workspace | 2 +- test/dnup.Tests/ReleaseManifestTests.cs | 44 ++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 5ff89ea4614b..9ae2d278b13b 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -200,7 +200,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts") /// InstallMode.SDK or InstallMode.Runtime /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) @@ -211,6 +211,20 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return null; } + // Check for special channel strings (case insensitive) + if (string.Equals(channel, "lts", StringComparison.OrdinalIgnoreCase)) + { + // Handle LTS (Long-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: true, mode); + } + else if (string.Equals(channel, "sts", StringComparison.OrdinalIgnoreCase)) + { + // Handle STS (Standard-Term Support) channel + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionBySupportStatus(productIndex, isLts: false, mode); + } + // Parse the channel string into components var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -281,6 +295,93 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } } + /// + /// Gets the latest version based on support status (LTS or STS). + /// + /// The product collection to search + /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) + /// InstallMode.SDK or InstallMode.Runtime + /// Latest stable version string matching the support status, or null if none found + private string? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallMode mode) + { + // Get all products + var allProducts = index.ToList(); + + // LTS versions typically have even minor versions (e.g., 6.0, 8.0, 10.0) + // STS versions typically have odd minor versions (e.g., 7.0, 9.0, 11.0) + var filteredProducts = allProducts.Where(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion)) + { + // For LTS, we want even minor versions (0, 2, 4, etc.) + // For STS, we want odd minor versions (1, 3, 5, etc.) + bool isEvenMinor = minorVersion % 2 == 0; + return isLts ? isEvenMinor : !isEvenMinor; + } + return false; + }).ToList(); + + // Order by major and minor version (descending) to get the most recent first + filteredProducts = filteredProducts + .OrderByDescending(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) + { + return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); + } + return 0; + }) + .ToList(); + + // Get all releases from filtered products + foreach (var product in filteredProducts) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter out preview versions + var stableReleases = releases + .Where(r => !r.IsPreview) + .ToList(); + + if (!stableReleases.Any()) + { + continue; // No stable releases for this product, try next one + } + + // Find latest version based on mode + if (mode == InstallMode.SDK) + { + var sdks = stableReleases + .SelectMany(r => r.Sdks) + .Where(sdk => !sdk.Version.ToString().Contains("-")) // Exclude any preview/RC versions + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (sdks.Any()) + { + return sdks.First().Version.ToString(); + } + } + else // Runtime mode + { + var runtimes = stableReleases + .SelectMany(r => r.Runtimes) + .Where(runtime => !runtime.Version.ToString().Contains("-")) // Exclude any preview/RC versions + .OrderByDescending(runtime => runtime.Version) + .ToList(); + + if (runtimes.Any()) + { + return runtimes.First().Version.ToString(); + } + } + } + + return null; // No matching versions found + } + /// /// Gets the latest version for a major.minor channel (e.g., "9.0"). /// diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace index e0349b05a271..6e8de4bb18ae 100644 --- a/src/Installer/dnup/dnup.code-workspace +++ b/src/Installer/dnup/dnup.code-workspace @@ -5,7 +5,7 @@ "name": "dnup" }, { - "path": "../../../../test/dnup.Tests", + "path": "../../../test/dnup.Tests", "name": "dnup.Tests" } ], diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 3484a9a38ba1..52c72812de97 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -35,5 +35,49 @@ public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() Assert.True(!string.IsNullOrEmpty(version)); Assert.Matches(@"^9\.0\.1\d{2}$", version); } + + [Fact] + public void GetLatestVersionForChannel_LTS_ReturnsLatestLTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("lts", InstallMode.SDK); + + Console.WriteLine($"LTS Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // LTS versions should have even minor versions (e.g., 6.0, 8.0, 10.0) + var versionParts = version.Split('.'); + Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); + + int minorVersion = int.Parse(versionParts[1]); + Assert.True(minorVersion % 2 == 0, $"LTS version {version} should have an even minor version"); + + // Should not be a preview version + Assert.DoesNotContain("-", version); + } + + [Fact] + public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("sts", InstallMode.SDK); + + Console.WriteLine($"STS Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // STS versions should have odd minor versions (e.g., 7.0, 9.0, 11.0) + var versionParts = version.Split('.'); + Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); + + int minorVersion = int.Parse(versionParts[1]); + Assert.True(minorVersion % 2 != 0, $"STS version {version} should have an odd minor version"); + + // Should not be a preview version + Assert.DoesNotContain("-", version); + } } } From 681ab9887d911cc201a929f425f8418464471d7d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 5 Sep 2025 16:25:58 -0700 Subject: [PATCH 052/153] add preview support. --- src/Installer/dnup/ReleaseManifest.cs | 81 +++++++++++++++++++++++-- test/dnup.Tests/ReleaseManifestTests.cs | 24 ++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 9ae2d278b13b..b01a333b97ca 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -200,7 +200,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// - /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts") + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts", "preview") /// InstallMode.SDK or InstallMode.Runtime /// Latest fully specified version string, or null if not found public string? GetLatestVersionForChannel(string channel, InstallMode mode) @@ -224,8 +224,12 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); return GetLatestVersionBySupportStatus(productIndex, isLts: false, mode); } - - // Parse the channel string into components + else if (string.Equals(channel, "preview", StringComparison.OrdinalIgnoreCase)) + { + // Handle Preview channel - get the latest preview version + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestPreviewVersion(productIndex, mode); + } // Parse the channel string into components var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); // If major is invalid, return null @@ -383,8 +387,77 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } /// - /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// Gets the latest preview version available. /// + /// The product collection to search + /// InstallMode.SDK or InstallMode.Runtime + /// Latest preview version string, or null if none found + private string? GetLatestPreviewVersion(ProductCollection index, InstallMode mode) + { + // Get all products + var allProducts = index.ToList(); + + // Order by major and minor version (descending) to get the most recent first + var sortedProducts = allProducts + .OrderByDescending(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) + { + return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); + } + return 0; + }) + .ToList(); + + // Get all releases from products + foreach (var product in sortedProducts) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + + // Filter for preview versions + var previewReleases = releases + .Where(r => r.IsPreview) + .ToList(); + + if (!previewReleases.Any()) + { + continue; // No preview releases for this product, try next one + } + + // Find latest version based on mode + if (mode == InstallMode.SDK) + { + var sdks = previewReleases + .SelectMany(r => r.Sdks) + .Where(sdk => sdk.Version.ToString().Contains("-")) // Include only preview/RC versions + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (sdks.Any()) + { + return sdks.First().Version.ToString(); + } + } + else // Runtime mode + { + var runtimes = previewReleases + .SelectMany(r => r.Runtimes) + .Where(runtime => runtime.Version.ToString().Contains("-")) // Include only preview/RC versions + .OrderByDescending(runtime => runtime.Version) + .ToList(); + + if (runtimes.Any()) + { + return runtimes.First().Version.ToString(); + } + } + } + + return null; // No preview versions found + } /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// private string? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallMode mode) { // Find the product for the requested major.minor diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 52c72812de97..81e88633c8dd 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -79,5 +79,29 @@ public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() // Should not be a preview version Assert.DoesNotContain("-", version); } + + [Fact] + public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel("preview", InstallMode.SDK); + + Console.WriteLine($"Preview Version found: {version ?? "null"}"); + + // Check that we got a version + Assert.False(string.IsNullOrEmpty(version)); + + // Preview versions should contain a hyphen (e.g., "11.0.0-preview.1") + Assert.Contains("-", version); + + // Should contain preview, rc, beta, or alpha + Assert.True( + version.Contains("preview", StringComparison.OrdinalIgnoreCase) || + version.Contains("rc", StringComparison.OrdinalIgnoreCase) || + version.Contains("beta", StringComparison.OrdinalIgnoreCase) || + version.Contains("alpha", StringComparison.OrdinalIgnoreCase), + $"Version {version} should be a preview/rc/beta/alpha version" + ); + } } } From 70a828f751790dda948b12bba5a358b553684b0b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 8 Sep 2025 10:22:48 -0700 Subject: [PATCH 053/153] Fix LTS STS Parsing --- src/Installer/dnup/ReleaseManifest.cs | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index b01a333b97ca..603a55b337ed 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -311,23 +311,10 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Get all products var allProducts = index.ToList(); - // LTS versions typically have even minor versions (e.g., 6.0, 8.0, 10.0) - // STS versions typically have odd minor versions (e.g., 7.0, 9.0, 11.0) - var filteredProducts = allProducts.Where(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion)) - { - // For LTS, we want even minor versions (0, 2, 4, etc.) - // For STS, we want odd minor versions (1, 3, 5, etc.) - bool isEvenMinor = minorVersion % 2 == 0; - return isLts ? isEvenMinor : !isEvenMinor; - } - return false; - }).ToList(); - - // Order by major and minor version (descending) to get the most recent first - filteredProducts = filteredProducts + // Use ReleaseType from manifest (dotnetreleases library) + var targetType = isLts ? ReleaseType.LTS : ReleaseType.STS; + var filteredProducts = allProducts + .Where(p => p.ReleaseType == targetType) .OrderByDescending(p => { var productParts = p.ProductVersion.Split('.'); From 8d455777de4c352039040cad71f28f0f68dc93d0 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 26 Sep 2025 11:19:55 -0400 Subject: [PATCH 054/153] Add dnup tests to dnup.slnf --- dnup.slnf | 1 + 1 file changed, 1 insertion(+) diff --git a/dnup.slnf b/dnup.slnf index f2557db6ee09..d68938fd2ae0 100644 --- a/dnup.slnf +++ b/dnup.slnf @@ -3,6 +3,7 @@ "path": "sdk.slnx", "projects": [ "src\\Installer\\dnup\\dnup.csproj", + "test\\dnup.Tests\\dnup.Tests.csproj", ] } } \ No newline at end of file From 29b70539463517ee0de2f6b040c2c6c5b2ad9f90 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 29 Sep 2025 11:06:10 -0400 Subject: [PATCH 055/153] Move sdk update command to dnup --- src/Cli/dotnet/CliUsage.cs | 2 -- src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs | 6 +----- src/Cli/dotnet/Parser.cs | 4 ---- .../dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs | 2 +- src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs | 6 +++--- .../dnup}/Commands/Sdk/Update/SdkUpdateCommandParser.cs | 8 ++++---- src/Installer/dnup/Parser.cs | 4 ++++ 7 files changed, 13 insertions(+), 19 deletions(-) rename src/{Cli/dotnet => Installer/dnup}/Commands/Sdk/Update/SdkUpdateCommandParser.cs (84%) diff --git a/src/Cli/dotnet/CliUsage.cs b/src/Cli/dotnet/CliUsage.cs index 50b77bf0351f..8e0870a86e84 100644 --- a/src/Cli/dotnet/CliUsage.cs +++ b/src/Cli/dotnet/CliUsage.cs @@ -43,7 +43,6 @@ internal static class CliUsage clean {CliCommandStrings.CleanDefinition} format {CliCommandStrings.FormatDefinition} help {CliCommandStrings.HelpDefinition} - install Installs the .NET SDK msbuild {CliCommandStrings.MsBuildDefinition} new {CliCommandStrings.NewDefinition} nuget {CliCommandStrings.NugetDefinition} @@ -58,7 +57,6 @@ install Installs the .NET SDK store {CliCommandStrings.StoreDefinition} test {CliCommandStrings.TestDefinition} tool {CliCommandStrings.ToolDefinition} - update Updates the .NET SDK vstest {CliCommandStrings.VsTestDefinition} workload {CliCommandStrings.WorkloadDefinition} diff --git a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs index e030768e14ab..061eed3dbdf2 100644 --- a/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs +++ b/src/Cli/dotnet/Commands/Sdk/SdkCommandParser.cs @@ -5,8 +5,6 @@ using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Sdk.Check; -//using Microsoft.DotNet.Cli.Commands.Sdk.Install; -using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Sdk; @@ -26,10 +24,8 @@ private static Command ConstructCommand() { DocumentedCommand command = new("sdk", DocsLink, CliCommandStrings.SdkAppFullName); command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); - //command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); - command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); - //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + command.SetAction((parseResult) => parseResult.HandleMissingCommand()); return command; } diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 8fcc522bcf30..4bddb07f976e 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -35,8 +35,6 @@ using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Commands.Run.Api; using Microsoft.DotNet.Cli.Commands.Sdk; -//using Microsoft.DotNet.Cli.Commands.Sdk.Install; -using Microsoft.DotNet.Cli.Commands.Sdk.Update; using Microsoft.DotNet.Cli.Commands.Solution; using Microsoft.DotNet.Cli.Commands.Store; using Microsoft.DotNet.Cli.Commands.Test; @@ -89,8 +87,6 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), - //SdkInstallCommandParser.GetRootInstallCommand(), - SdkUpdateCommandParser.GetRootUpdateCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index 7d4418fa0ec9..9c28335cc124 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -45,7 +45,7 @@ public static Command GetSdkInstallCommand() return SdkInstallCommand; } - // Trying to use the same command object for both "dotnet install" and "dotnet sdk install" causes the following exception: + // Trying to use the same command object for both "dnup install" and "dnup sdk install" causes the following exception: // System.InvalidOperationException: Command install has more than one child named "channel". // So we create a separate instance for each case private static readonly Command RootInstallCommand = ConstructCommand(); diff --git a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs index eb525222fd02..efd9de75d60b 100644 --- a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs @@ -6,6 +6,7 @@ using System.CommandLine; using System.Text; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk { @@ -20,10 +21,9 @@ public static Command GetCommand() private static Command ConstructCommand() { - Command command = new("sdk"); - //command.Subcommands.Add(SdkCheckCommandParser.GetCommand()); + Command command = new("sdk", "Manage sdk installations"); command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); - //command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); diff --git a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs similarity index 84% rename from src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs rename to src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs index 6cd55766d9c9..f419e0a28cf3 100644 --- a/src/Cli/dotnet/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.DotNet.Tools.Bootstrapper; -namespace Microsoft.DotNet.Cli.Commands.Sdk.Update; +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; internal static class SdkUpdateCommandParser { @@ -21,7 +21,7 @@ internal static class SdkUpdateCommandParser Arity = ArgumentArity.Zero }; - public static readonly Option InteractiveOption = CommonOptions.InteractiveOption(); + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; private static readonly Command SdkUpdateCommand = ConstructCommand(); @@ -30,7 +30,7 @@ public static Command GetSdkUpdateCommand() return SdkUpdateCommand; } - // Trying to use the same command object for both "dotnet udpate" and "dotnet sdk update" causes an InvalidOperationException + // Trying to use the same command object for both "dnup udpate" and "dnup sdk update" causes an InvalidOperationException // So we create a separate instance for each case private static readonly Command RootUpdateCommand = ConstructCommand(); diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs index 286a8a7d255d..7032270df288 100644 --- a/src/Installer/dnup/Parser.cs +++ b/src/Installer/dnup/Parser.cs @@ -7,6 +7,8 @@ using System.CommandLine.Completions; using System.Text; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; namespace Microsoft.DotNet.Tools.Bootstrapper { @@ -34,6 +36,8 @@ internal class Parser private static RootCommand ConfigureCommandLine(RootCommand rootCommand) { rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); + rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand()); + rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand()); return rootCommand; } From 97b7c35682603bd1bb6cd0233b1d3aa82e8cb8c4 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 2 Oct 2025 10:50:52 -0400 Subject: [PATCH 056/153] Misc cleanup --- src/Cli/dotnet/dotnet.csproj | 1 - src/Installer/dnup/BootstrapperController.cs | 19 +++++++----------- .../Commands/Sdk/Install/SdkInstallCommand.cs | 20 ++++--------------- src/Installer/dnup/DnupUtilities.cs | 16 +++++++++++++++ 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index f133ad234ca9..156dc1027688 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -61,7 +61,6 @@ - diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index d1f431d172f1..0b9b37305912 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -34,13 +34,15 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) - || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); - + bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || + installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + if (isAdminInstall) { // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir - if (!string.IsNullOrEmpty(dotnetRoot) && !PathsEqual(dotnetRoot, installDir) && !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(dotnetRoot) && !DnupUtilities.PathsEqual(dotnetRoot, installDir) && + !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && + !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) { return InstallType.Inconsistent; } @@ -49,7 +51,7 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) else { // User install: DOTNET_ROOT must be set and match installDir - if (string.IsNullOrEmpty(dotnetRoot) || !PathsEqual(dotnetRoot, installDir)) + if (string.IsNullOrEmpty(dotnetRoot) || !DnupUtilities.PathsEqual(dotnetRoot, installDir)) { return InstallType.Inconsistent; } @@ -57,13 +59,6 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) } } - private static bool PathsEqual(string a, string b) - { - return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - StringComparison.OrdinalIgnoreCase); - } - public string GetDefaultDotnetInstallPath() { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index d26da67167ad..829172b9fd59 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -27,16 +27,6 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) public override int Execute() { - //bool? updateGlobalJson = null; - - //var updateGlobalJsonOption = _parseResult.GetResult(SdkInstallCommandParser.UpdateGlobalJsonOption)!; - //if (updateGlobalJsonOption.Implicit) - //{ - - //} - - //Reporter.Output.WriteLine($"Update global.json: {_updateGlobalJson}"); - var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); string? currentInstallPath; @@ -50,8 +40,7 @@ public override int Execute() installPathFromGlobalJson = globalJsonInfo.SdkPath; if (installPathFromGlobalJson != null && _installPath != null && - // TODO: Is there a better way to compare paths that takes into account whether the file system is case-sensitive? - !installPathFromGlobalJson.Equals(_installPath, StringComparison.OrdinalIgnoreCase)) + !DnupUtilities.PathsEqual(installPathFromGlobalJson, _installPath)) { // TODO: Add parameter to override error Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); @@ -152,8 +141,7 @@ public override int Execute() } else if (defaultInstallState == InstallType.User) { - // Another case where we need to compare paths and the comparison may or may not need to be case-sensitive - if (resolvedInstallPath.Equals(currentInstallPath, StringComparison.OrdinalIgnoreCase)) + if (DnupUtilities.PathsEqual(resolvedInstallPath, currentInstallPath)) { // No need to prompt here, the default install is already set up. } @@ -193,7 +181,7 @@ public override int Execute() resolvedInstallPath, InstallType.User, InstallMode.SDK, - DnupUtilities.GetInstallArchitecture(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture), + DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), new ManagementCadence(ManagementCadenceType.DNUP), new InstallRequestOptions()); @@ -219,7 +207,7 @@ public override int Execute() } else { - // TODO: Add command-linen option for installing admin versions locally + // TODO: Add command-line option for installing admin versions locally } } diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index e759ee3eea46..0b8a2ccb5a26 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -18,6 +18,22 @@ public static string GetDotnetExeName() return "dotnet" + ExeSuffix; } + public static bool PathsEqual(string? a, string? b) + { + if (a == null && b == null) + { + return true; + } + else if (a == null || b == null) + { + return false; + } + + return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) { return architecture switch From b77138252aced92bb4fd702e44945202c8df9a45 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 2 Oct 2025 20:52:08 -0400 Subject: [PATCH 057/153] Cleanup --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- .../dnup/Commands/Sdk/Install/SdkInstallCommand.cs | 6 ------ src/Installer/dnup/DnupSharedManifest.cs | 7 +++---- src/Installer/dnup/DnupUtilities.cs | 2 +- src/Installer/dnup/ReleaseManifest.cs | 2 +- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 71f9a9e4d7a2..cc42922366a8 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -29,7 +29,7 @@ public void Prepare() { using var releaseManifest = new ReleaseManifest(); var archiveName = $"dotnet-{_install.Id}"; - _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetFileExtensionForPlatform()); + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); Spectre.Console.AnsiConsole.Progress() .Start(ctx => diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 829172b9fd59..cfbeb2d195c4 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -278,10 +278,4 @@ public override int Execute() return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } - bool IsElevated() - { - return false; - } - - // ...existing code... } diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 863eb18a92e5..dc935366ee97 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -66,7 +66,7 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v } catch (JsonException ex) { - throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible: {ex.Message}"); + throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible", ex); } } @@ -79,10 +79,9 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v public IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null) { return GetInstalledVersions(validator) - .Where(install => string.Equals( + .Where(install => DnupUtilities.PathsEqual( Path.GetFullPath(install.MuxerDirectory), - Path.GetFullPath(muxerDirectory), - StringComparison.OrdinalIgnoreCase)); + Path.GetFullPath(muxerDirectory))); } public void AddInstalledVersion(DotnetInstall version) diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index 0b8a2ccb5a26..c2b5a901918d 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -77,7 +77,7 @@ public static string GetRuntimeIdentifier(InstallArchitecture architecture) return $"{os}-{arch}"; } - public static string GetFileExtensionForPlatform() + public static string GetArchiveFileExtensionForPlatform() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 603a55b337ed..9b9dd7140c81 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -927,7 +927,7 @@ private static ProductCollection DeserializeProductCollection(string json) private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstall install) { var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); - var fileExtension = DnupUtilities.GetFileExtensionForPlatform(); + var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); // Determine the component type pattern to look for in file names string componentTypePattern; From deeab320dc83d11da4886d918df55211b27981ef Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 3 Oct 2025 14:43:52 -0400 Subject: [PATCH 058/153] Remove dnup to dotnet shims --- src/Layout/redist/dnup | 6 ------ src/Layout/redist/dnup.cmd | 6 ------ src/Layout/redist/targets/GenerateInstallerLayout.targets | 2 -- 3 files changed, 14 deletions(-) delete mode 100644 src/Layout/redist/dnup delete mode 100644 src/Layout/redist/dnup.cmd diff --git a/src/Layout/redist/dnup b/src/Layout/redist/dnup deleted file mode 100644 index 04758bf9f8d4..000000000000 --- a/src/Layout/redist/dnup +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -if [ $# -eq 0 ]; then - "$(dirname "$0")/dotnet" install -else - "$(dirname "$0")/dotnet" "$@" -fi diff --git a/src/Layout/redist/dnup.cmd b/src/Layout/redist/dnup.cmd deleted file mode 100644 index 7d8cb5bc7af0..000000000000 --- a/src/Layout/redist/dnup.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -if "%~1"=="" ( - "%~dp0dotnet.exe" install -) else ( - "%~dp0dotnet.exe" %* -) diff --git a/src/Layout/redist/targets/GenerateInstallerLayout.targets b/src/Layout/redist/targets/GenerateInstallerLayout.targets index 14adead05b41..4c7aa7749e3b 100644 --- a/src/Layout/redist/targets/GenerateInstallerLayout.targets +++ b/src/Layout/redist/targets/GenerateInstallerLayout.targets @@ -68,9 +68,7 @@ - - From 8437d6be5c3a0ef6c5a1d252cd17f77361db037c Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 6 Oct 2025 20:52:49 -0400 Subject: [PATCH 059/153] Refactor DotnetInstall types --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 49 ++--- src/Installer/dnup/BootstrapperController.cs | 34 ++-- .../EnvironmentVariableMockDotnetInstaller.cs | 14 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 56 +++--- src/Installer/dnup/DnupSharedManifest.cs | 10 +- src/Installer/dnup/DnupUtilities.cs | 5 + src/Installer/dnup/DotnetInstall.cs | 41 ++--- src/Installer/dnup/IBootstrapperController.cs | 4 +- src/Installer/dnup/IDnupManifest.cs | 3 +- .../{InstallMode.cs => InstallComponent.cs} | 3 +- .../dnup/InstallerOrchestratorSingleton.cs | 35 ++-- .../dnup/ManifestChannelVersionResolver.cs | 22 +-- src/Installer/dnup/ReleaseManifest.cs | 174 ++++++++---------- src/Installer/dnup/UpdateChannel.cs | 26 +++ test/dnup.Tests/DotnetInstallTests.cs | 75 -------- 15 files changed, 237 insertions(+), 314 deletions(-) rename src/Installer/dnup/{InstallMode.cs => InstallComponent.cs} (89%) create mode 100644 src/Installer/dnup/UpdateChannel.cs delete mode 100644 test/dnup.Tests/DotnetInstallTests.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index cc42922366a8..1510a597f220 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -8,38 +8,39 @@ using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { private readonly DotnetInstallRequest _request; - private readonly DotnetInstall _install; + private readonly ReleaseVersion _resolvedVersion; private string scratchDownloadDirectory; private string? _archivePath; - public ArchiveDotnetInstaller(DotnetInstallRequest request, DotnetInstall version) + public ArchiveDotnetInstaller(DotnetInstallRequest request, ReleaseVersion resolvedVersion) { _request = request; - _install = version; + _resolvedVersion = resolvedVersion; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; } public void Prepare() { using var releaseManifest = new ReleaseManifest(); - var archiveName = $"dotnet-{_install.Id}"; + var archiveName = $"dotnet-{Guid.NewGuid()}"; _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); Spectre.Console.AnsiConsole.Progress() .Start(ctx => { - var downloadTask = ctx.AddTask($"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); - var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_install.FullySpecifiedVersion.Value}"); - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_install, _archivePath, reporter); + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_resolvedVersion}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); if (!downloadSuccess) { - throw new InvalidOperationException($"Failed to download .NET archive for version {_install.FullySpecifiedVersion.Value}"); + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); } downloadTask.Value = 100; @@ -83,10 +84,10 @@ internal static string ConstructArchiveName(string? versionString, string rid, s public void Commit() { - Commit(GetExistingSdkVersions(_request.TargetDirectory)); + Commit(GetExistingSdkVersions(_request.InstallRoot)); } - public void Commit(IEnumerable existingSdkVersions) + public void Commit(IEnumerable existingSdkVersions) { if (_archivePath == null || !File.Exists(_archivePath)) { @@ -96,10 +97,10 @@ public void Commit(IEnumerable existingSdkVersions) Spectre.Console.AnsiConsole.Progress() .Start(ctx => { - var installTask = ctx.AddTask($"Installing .NET SDK {_install.FullySpecifiedVersion.Value}", autoStart: true); + var installTask = ctx.AddTask($"Installing .NET SDK {_resolvedVersion}", autoStart: true); // Extract archive directly to target directory with special handling for muxer - var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.TargetDirectory, existingSdkVersions, installTask); + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); if (extractResult != null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); @@ -113,7 +114,7 @@ public void Commit(IEnumerable existingSdkVersions) * Extracts the archive directly to the target directory with special handling for muxer. * Combines extraction and installation into a single operation. */ - private string? ExtractArchiveDirectlyToTarget(string archivePath, string targetDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? installTask) + private string? ExtractArchiveDirectlyToTarget(string archivePath, string targetDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? installTask) { try { @@ -140,14 +141,14 @@ public void Commit(IEnumerable existingSdkVersions) /** * Configure muxer handling by determining if it needs to be updated. */ - private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) + private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) { - DotnetVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (DotnetVersion?)null; - DotnetVersion newRuntimeVersion = _install.FullySpecifiedVersion; + ReleaseVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (ReleaseVersion?)null; + ReleaseVersion newRuntimeVersion = _resolvedVersion; bool shouldUpdateMuxer = existingMuxerVersion is null || newRuntimeVersion.CompareTo(existingMuxerVersion) > 0; string muxerName = DnupUtilities.GetDotnetExeName(); - string muxerTargetPath = Path.Combine(_request.TargetDirectory, muxerName); + string muxerTargetPath = Path.Combine(_request.InstallRoot.Path!, muxerName); return new MuxerHandlingConfig( muxerName, @@ -421,12 +422,16 @@ public void Dispose() } } + // TODO: InstallerOrchestratorSingleton also checks existing installs via the manifest. Which should we use and where? // This should be cached and more sophisticated based on vscode logic in the future - private IEnumerable GetExistingSdkVersions(string targetDirectory) + private IEnumerable GetExistingSdkVersions(DotnetInstallRoot installRoot) { - var dotnetExe = Path.Combine(targetDirectory, DnupUtilities.GetDotnetExeName()); + if (installRoot.Path == null) + return Enumerable.Empty(); + + var dotnetExe = Path.Combine(installRoot.Path, DnupUtilities.GetDotnetExeName()); if (!File.Exists(dotnetExe)) - return Enumerable.Empty(); + return Enumerable.Empty(); try { @@ -440,14 +445,14 @@ private IEnumerable GetExistingSdkVersions(string targetDirectory var output = process.StandardOutput.ReadToEnd(); process.WaitForExit(); - var versions = new List(); + var versions = new List(); foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { var parts = line.Split(' '); if (parts.Length > 0) { var versionStr = parts[0]; - if (DotnetVersion.TryParse(versionStr, out var version)) + if (ReleaseVersion.TryParse(versionStr, out var version)) { versions.Add(version); } diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index 0b9b37305912..3e7a6b1e888a 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -19,17 +19,17 @@ public BootstrapperController(IEnvironmentProvider? environmentProvider = null) _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } - public InstallType GetConfiguredInstallType(out string? currentInstallPath) + public DotnetInstallRoot GetConfiguredInstallType() { - currentInstallPath = null; + string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); if (string.IsNullOrEmpty(foundDotnet)) { - return InstallType.None; + return new(null, InstallType.None, DnupUtilities.GetDefaultInstallArchitecture()); } string installDir = Path.GetDirectoryName(foundDotnet)!; - currentInstallPath = installDir; + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); @@ -44,18 +44,18 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) { - return InstallType.Inconsistent; + return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); } - return InstallType.Admin; + return new(installDir, InstallType.Admin, DnupUtilities.GetDefaultInstallArchitecture()); } else { // User install: DOTNET_ROOT must be set and match installDir if (string.IsNullOrEmpty(dotnetRoot) || !DnupUtilities.PathsEqual(dotnetRoot, installDir)) { - return InstallType.Inconsistent; + return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); } - return InstallType.User; + return new(installDir, InstallType.User, DnupUtilities.GetDefaultInstallArchitecture()); } } @@ -96,35 +96,31 @@ public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) return null; } - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) { foreach (var channelVersion in sdkVersions) { - InstallSDK(dotnetRoot, progressContext, channelVersion); + InstallSDK(dotnetRoot, progressContext, new UpdateChannel(channelVersion)); } } - private void InstallSDK(string dotnetRoot, ProgressContext progressContext, string channelVersion) + private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, UpdateChannel channnel) { DotnetInstallRequest request = new DotnetInstallRequest( - channelVersion, dotnetRoot, - InstallType.User, - InstallMode.SDK, - // Get current machine architecture and convert it to correct enum value - DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), - new ManagementCadence(ManagementCadenceType.DNUP), + channnel, + InstallComponent.SDK, new InstallRequestOptions() ); DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); if (newInstall == null) { - throw new Exception($"Failed to install .NET SDK {channelVersion}"); + throw new Exception($"Failed to install .NET SDK {channnel.Name}"); } else { - Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.FullySpecifiedVersion}, available via {newInstall.MuxerDirectory}[/]"); + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.Version}, available via {newInstall.InstallRoot}[/]"); } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs index 50004365e49c..4617d65eabe8 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -27,16 +27,16 @@ public string GetDefaultDotnetInstallPath() return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public InstallType GetConfiguredInstallType(out string? currentInstallPath) + public DotnetInstallRoot GetConfiguredInstallType() { var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - InstallType returnValue = InstallType.None; - if (!Enum.TryParse(testHookDefaultInstall, out returnValue)) + InstallType installtype = InstallType.None; + if (!Enum.TryParse(testHookDefaultInstall, out installtype)) { - returnValue = InstallType.None; + installtype = InstallType.None; } - currentInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return returnValue; + var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); + return new(installPath, installtype, DnupUtilities.GetDefaultInstallArchitecture()); } public string? GetLatestInstalledAdminVersion() @@ -49,7 +49,7 @@ public InstallType GetConfiguredInstallType(out string? currentInstallPath) return latestAdminVersion; } - public void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) { using (var httpClient = new HttpClient()) { diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index cfbeb2d195c4..888f0f65e689 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -29,8 +29,7 @@ public override int Execute() { var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); - string? currentInstallPath; - InstallType defaultInstallState = _dotnetInstaller.GetConfiguredInstallType(out currentInstallPath); + var currentDotnetInstallRoot = _dotnetInstaller.GetConfiguredInstallType(); string? resolvedInstallPath = null; @@ -55,10 +54,10 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && defaultInstallState == InstallType.User) + if (resolvedInstallPath == null && currentDotnetInstallRoot.Type == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path - resolvedInstallPath = currentInstallPath; + resolvedInstallPath = currentDotnetInstallRoot.Path; } if (resolvedInstallPath == null) @@ -133,35 +132,35 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (defaultInstallState == InstallType.None) + if (currentDotnetInstallRoot.Type == InstallType.None) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == InstallType.User) + else if (currentDotnetInstallRoot.Type == InstallType.User) { - if (DnupUtilities.PathsEqual(resolvedInstallPath, currentInstallPath)) + if (DnupUtilities.PathsEqual(resolvedInstallPath, currentDotnetInstallRoot.Path)) { // No need to prompt here, the default install is already set up. } else { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( - $"The default dotnet install is currently set to {currentInstallPath}. Do you want to change it to {resolvedInstallPath}?", + $"The default dotnet install is currently set to {currentDotnetInstallRoot.Path}. Do you want to change it to {resolvedInstallPath}?", defaultValue: false); } } - else if (defaultInstallState == InstallType.Admin) + else if (currentDotnetInstallRoot.Type == InstallType.Admin) { - SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentInstallPath}. We can configure your system to use the new install of .NET " + + SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentDotnetInstallRoot.Path}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (defaultInstallState == InstallType.Inconsistent) + else if (currentDotnetInstallRoot.Type == InstallType.Inconsistent) { // TODO: Figure out what to do here resolvedSetDefaultInstall = false; @@ -177,23 +176,19 @@ public override int Execute() // Create a request and resolve it using the channel version resolver var installRequest = new DotnetInstallRequest( - resolvedChannel, - resolvedInstallPath, - InstallType.User, - InstallMode.SDK, - DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), - new ManagementCadence(ManagementCadenceType.DNUP), + new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new UpdateChannel(resolvedChannel), + InstallComponent.SDK, new InstallRequestOptions()); - var resolvedInstall = _channelVersionResolver.Resolve(installRequest); - var resolvedChannelVersion = resolvedInstall.FullySpecifiedVersion.Value; + var resolvedVersion = _channelVersionResolver.Resolve(installRequest); - if (resolvedSetDefaultInstall == true && defaultInstallState == InstallType.Admin) + if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot.Type == InstallType.Admin) { if (_interactive) { var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); - if (latestAdminVersion != null && new ReleaseVersion(resolvedChannelVersion) < new ReleaseVersion(latestAdminVersion)) + if (latestAdminVersion != null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) { SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); @@ -213,7 +208,7 @@ public override int Execute() // TODO: Implement transaction / rollback? - SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedChannelVersion}[/] to [blue]{resolvedInstallPath}[/]..."); + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedVersion}[/] to [blue]{resolvedInstallPath}[/]..."); // Create and use a progress context var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); @@ -222,22 +217,19 @@ public override int Execute() DotnetInstall? mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); if (mainInstall == null) { - SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedChannelVersion}[/]"); + SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); return 1; } - SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.FullySpecifiedVersion}, available via {mainInstall.MuxerDirectory}[/]"); + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.Version}, available via {mainInstall.InstallRoot}[/]"); // Install any additional versions foreach (var additionalVersion in additionalVersionsToInstall) { // Create the request for the additional version var additionalRequest = new DotnetInstallRequest( - additionalVersion, - resolvedInstallPath, - InstallType.User, - InstallMode.SDK, - DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture), - new ManagementCadence(ManagementCadenceType.DNUP), + new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new UpdateChannel(additionalVersion), + InstallComponent.SDK, new InstallRequestOptions()); // Install the additional version directly using InstallerOrchestratorSingleton @@ -248,7 +240,7 @@ public override int Execute() } else { - SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.FullySpecifiedVersion}, available via {additionalInstall.MuxerDirectory}[/]"); + SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.Version}, available via {additionalInstall.InstallRoot}[/]"); } } @@ -259,7 +251,7 @@ public override int Execute() if (resolvedUpdateGlobalJson == true) { - _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedChannelVersion, globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); + _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedVersion!.ToString(), globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); } diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index dc935366ee97..a1b11a869bfa 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -73,15 +73,15 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v /// /// Gets installed versions filtered by a specific muxer directory. /// - /// Directory to filter by (must match the MuxerDirectory property) + /// Directory to filter by (must match the InstallRoot property) /// Optional validator to check installation validity /// Installations that match the specified directory - public IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null) + public IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null) { return GetInstalledVersions(validator) .Where(install => DnupUtilities.PathsEqual( - Path.GetFullPath(install.MuxerDirectory), - Path.GetFullPath(muxerDirectory))); + Path.GetFullPath(install.InstallRoot.Path!), + Path.GetFullPath(installRoot.Path!))); } public void AddInstalledVersion(DotnetInstall version) @@ -102,7 +102,7 @@ public void RemoveInstalledVersion(DotnetInstall version) EnsureManifestExists(); var installs = GetInstalledVersions().ToList(); - installs.RemoveAll(i => i.Id == version.Id && i.FullySpecifiedVersion == version.FullySpecifiedVersion); + installs.RemoveAll(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, version.InstallRoot.Path) && i.Version.Equals(version.Version)); var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); File.WriteAllText(ManifestPath, json); } diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/dnup/DnupUtilities.cs index c2b5a901918d..5ef1738bb4a3 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/dnup/DnupUtilities.cs @@ -45,6 +45,11 @@ public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropS }; } + public static InstallArchitecture GetDefaultInstallArchitecture() + { + return GetInstallArchitecture(RuntimeInformation.ProcessArchitecture); + } + public static void ForceReplaceFile(string sourcePath, string destPath) { File.Copy(sourcePath, destPath, overwrite: true); diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 7ff4b756f224..470b73f530b8 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -2,24 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper; -/// -/// Base record for .NET installation information with common properties. -/// -public record DotnetInstallBase( - string ResolvedDirectory, +public record DotnetInstallRoot( + string? Path, InstallType Type, - InstallMode Mode, InstallArchitecture Architecture) { - public Guid Id { get; } = Guid.NewGuid(); -} - -public record InstallRequestOptions() -{ - // Include things such as the custom feed here. + // Do we need a GUID for the ID here? } /// @@ -27,21 +19,20 @@ public record InstallRequestOptions() /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// public record DotnetInstall( - DotnetVersion FullySpecifiedVersion, - string MuxerDirectory, - InstallType Type, - InstallMode Mode, - InstallArchitecture Architecture, - ManagementCadence Cadence) : DotnetInstallBase(MuxerDirectory, Type, Mode, Architecture); + DotnetInstallRoot InstallRoot, + ReleaseVersion Version, + InstallComponent Component); /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// public record DotnetInstallRequest( - string ChannelVersion, - string TargetDirectory, - InstallType Type, - InstallMode Mode, - InstallArchitecture Architecture, - ManagementCadence Cadence, - InstallRequestOptions Options) : DotnetInstallBase(Path.Combine(TargetDirectory, DnupUtilities.GetDotnetExeName()), Type, Mode, Architecture); + DotnetInstallRoot InstallRoot, + UpdateChannel Channel, + InstallComponent Component, + InstallRequestOptions Options); + +public record InstallRequestOptions() +{ + // Include things such as the custom feed here. +} diff --git a/src/Installer/dnup/IBootstrapperController.cs b/src/Installer/dnup/IBootstrapperController.cs index bc4dd5aa7e17..be4d7760352b 100644 --- a/src/Installer/dnup/IBootstrapperController.cs +++ b/src/Installer/dnup/IBootstrapperController.cs @@ -14,11 +14,11 @@ public interface IBootstrapperController string GetDefaultDotnetInstallPath(); - InstallType GetConfiguredInstallType(out string? currentInstallPath); + DotnetInstallRoot GetConfiguredInstallType(); string? GetLatestInstalledAdminVersion(); - void InstallSdks(string dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); + void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 252eefd41e65..4aa56b20dcb6 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -2,13 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper { internal interface IDnupManifest { IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); - IEnumerable GetInstalledVersions(string muxerDirectory, IInstallationValidator? validator = null); + IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null); void AddInstalledVersion(DotnetInstall version); void RemoveInstalledVersion(DotnetInstall version); } diff --git a/src/Installer/dnup/InstallMode.cs b/src/Installer/dnup/InstallComponent.cs similarity index 89% rename from src/Installer/dnup/InstallMode.cs rename to src/Installer/dnup/InstallComponent.cs index 329ac4e27416..a65f29c987b5 100644 --- a/src/Installer/dnup/InstallMode.cs +++ b/src/Installer/dnup/InstallComponent.cs @@ -3,11 +3,12 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { - public enum InstallMode + public enum InstallComponent { SDK, Runtime, ASPNETCore, WindowsDesktop } + } diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 0384e885e0c5..f17a6d64bc97 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -24,26 +25,37 @@ private InstallerOrchestratorSingleton() public DotnetInstall? Install(DotnetInstallRequest installRequest) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version - DotnetInstall install = new ManifestChannelVersionResolver().Resolve(installRequest); + ReleaseVersion? versionToInstall = new ManifestChannelVersionResolver().Resolve(installRequest); + + if (versionToInstall == null) + { + Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); + return null; + } + + DotnetInstall install = new( + installRequest.InstallRoot, + versionToInstall, + installRequest.Component); // Check if the install already exists and we don't need to do anything // read write mutex only for manifest? using (var finalizeLock = modifyInstallStateMutex()) { - if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + if (InstallAlreadyExists(install)) { - Console.WriteLine($"\n.NET SDK {install.FullySpecifiedVersion.Value} is already installed, skipping installation."); + Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); return install; } } - ArchiveDotnetInstaller installer = new(installRequest, install); + using ArchiveDotnetInstaller installer = new(installRequest, versionToInstall); installer.Prepare(); // Extract and commit the install to the directory using (var finalizeLock = modifyInstallStateMutex()) { - if (InstallAlreadyExists(installRequest.ResolvedDirectory, install)) + if (InstallAlreadyExists(install)) { return install; } @@ -68,24 +80,23 @@ private InstallerOrchestratorSingleton() /// /// Gets the existing installs from the manifest. Must hold a mutex over the directory. /// - private IEnumerable GetExistingInstalls(string directory) + private IEnumerable GetExistingInstalls(DotnetInstallRoot installRoot) { var manifestManager = new DnupSharedManifest(); // Use the overload that filters by muxer directory - return manifestManager.GetInstalledVersions(directory); + return manifestManager.GetInstalledVersions(installRoot); } /// /// Checks if the installation already exists. Must hold a mutex over the directory. /// - private bool InstallAlreadyExists(string directory, DotnetInstall install) + private bool InstallAlreadyExists(DotnetInstall install) { - var existingInstalls = GetExistingInstalls(directory); + var existingInstalls = GetExistingInstalls(install.InstallRoot); // Check if there's any existing installation that matches the version we're trying to install return existingInstalls.Any(existing => - existing.FullySpecifiedVersion.Value == install.FullySpecifiedVersion.Value && - existing.Type == install.Type && - existing.Architecture == install.Architecture); + existing.Version.Equals(install.Version) && + existing.Component == install.Component); } } diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 2b3c89aff8a2..506bed9cd416 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -3,34 +3,22 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Deployment.DotNet.Releases; using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ManifestChannelVersionResolver { - public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion) + public ReleaseVersion? Resolve(DotnetInstallRequest installRequest) { - string channel = dotnetChannelVersion.ChannelVersion; - DotnetVersion dotnetVersion = new DotnetVersion(channel); - // If not fully specified, resolve to latest using ReleaseManifest - if (!dotnetVersion.IsFullySpecified) + if (!installRequest.Channel.IsFullySpecifiedVersion()) { var manifest = new ReleaseManifest(); - var latestVersion = manifest.GetLatestVersionForChannel(channel, dotnetChannelVersion.Mode); - if (latestVersion != null) - { - dotnetVersion = new DotnetVersion(latestVersion); - } + return manifest.GetLatestVersionForChannel(installRequest.Channel, installRequest.Component); } - return new DotnetInstall( - dotnetVersion, - dotnetChannelVersion.ResolvedDirectory, - dotnetChannelVersion.Type, - dotnetChannelVersion.Mode, - dotnetChannelVersion.Architecture, - dotnetChannelVersion.Cadence); + return new ReleaseVersion(installRequest.Channel.Name); } } diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 9b9dd7140c81..4909cadb65cf 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -24,9 +24,9 @@ internal class ReleaseManifest : IDisposable /// /// Channel string to parse (e.g., "9", "9.0", "9.0.1xx", "9.0.103") /// Tuple containing (major, minor, featureBand, isFullySpecified) - private (int Major, int Minor, string? FeatureBand, bool IsFullySpecified) ParseVersionChannel(string channel) + private (int Major, int Minor, string? FeatureBand, bool IsFullySpecified) ParseVersionChannel(UpdateChannel channel) { - var parts = channel.Split('.'); + var parts = channel.Name.Split('.'); int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; @@ -88,7 +88,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Optional major version filter /// Optional minor version filter /// Latest SDK version string, or null if none found - private string? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) + private ReleaseVersion? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) { var allSdks = releases .SelectMany(r => r.Sdks) @@ -100,7 +100,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (allSdks.Any()) { - return allSdks.First().Version.ToString(); + return allSdks.First().Version; } return null; @@ -114,7 +114,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Optional minor version filter /// Optional runtime type filter (null for any runtime) /// Latest runtime version string, or null if none found - private string? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) + private ReleaseVersion? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) { var allRuntimes = releases.SelectMany(r => r.Runtimes).ToList(); @@ -155,7 +155,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (allRuntimes.Any()) { - return allRuntimes.OrderByDescending(r => r.Version).First().Version.ToString(); + return allRuntimes.OrderByDescending(r => r.Version).First().Version; } return null; @@ -169,7 +169,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Minor version /// Feature band prefix (e.g., "1" for "1xx") /// Latest matching version string, or fallback format if none found - private string? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) + private ReleaseVersion? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) { var allSdkComponents = releases.SelectMany(r => r.Sdks).ToList(); @@ -190,11 +190,11 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (featureBandSdks.Any()) { // Return the exact version from the latest matching SDK - return featureBandSdks.First().Version.ToString(); + return featureBandSdks.First().Version; } // Fallback if no actual release matches the feature band pattern - return $"{major}.{minor}.{featureBand}00"; + return null; } /// @@ -203,33 +203,28 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts", "preview") /// InstallMode.SDK or InstallMode.Runtime /// Latest fully specified version string, or null if not found - public string? GetLatestVersionForChannel(string channel, InstallMode mode) + public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) { - // If channel is null or empty, return null - if (string.IsNullOrEmpty(channel)) - { - return null; - } - // Check for special channel strings (case insensitive) - if (string.Equals(channel, "lts", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase)) { // Handle LTS (Long-Term Support) channel var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: true, mode); + return GetLatestVersionBySupportStatus(productIndex, isLts: true, component); } - else if (string.Equals(channel, "sts", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) { // Handle STS (Standard-Term Support) channel var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: false, mode); + return GetLatestVersionBySupportStatus(productIndex, isLts: false, component); } - else if (string.Equals(channel, "preview", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) { // Handle Preview channel - get the latest preview version var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestPreviewVersion(productIndex, mode); + return GetLatestPreviewVersion(productIndex, component); } // Parse the channel string into components + var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); // If major is invalid, return null @@ -241,7 +236,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // If the version is already fully specified, just return it as-is if (isFullySpecified) { - return channel; + return new ReleaseVersion(channel.Name); } // Load the index manifest @@ -250,19 +245,19 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Case 1: Major only version (e.g., "9") if (minor < 0) { - return GetLatestVersionForMajorOnly(index, major, mode); + return GetLatestVersionForMajorOnly(index, major, component); } // Case 2: Major.Minor version (e.g., "9.0") if (minor >= 0 && featureBand == null) { - return GetLatestVersionForMajorMinor(index, major, minor, mode); + return GetLatestVersionForMajorMinor(index, major, minor, component); } // Case 3: Feature band version (e.g., "9.0.1xx") if (minor >= 0 && featureBand != null) { - return GetLatestVersionForFeatureBand(index, major, minor, featureBand, mode); + return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); } return null; @@ -271,7 +266,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Gets the latest version for a major-only channel (e.g., "9"). /// - private string? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallMode mode) + private ReleaseVersion? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallComponent component) { // Get products matching the major version var matchingProducts = GetProductsForMajorVersion(index, major); @@ -289,7 +284,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } // Find the latest version based on mode - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { return GetLatestSdkVersion(allReleases, major); } @@ -304,9 +299,9 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// The product collection to search /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) - /// InstallMode.SDK or InstallMode.Runtime + /// InstallComponent.SDK or InstallComponent.Runtime /// Latest stable version string matching the support status, or null if none found - private string? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallMode mode) + private ReleaseVersion? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallComponent component) { // Get all products var allProducts = index.ToList(); @@ -342,7 +337,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } // Find latest version based on mode - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { var sdks = stableReleases .SelectMany(r => r.Sdks) @@ -352,7 +347,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (sdks.Any()) { - return sdks.First().Version.ToString(); + return sdks.First().Version; } } else // Runtime mode @@ -365,7 +360,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (runtimes.Any()) { - return runtimes.First().Version.ToString(); + return runtimes.First().Version; } } } @@ -377,9 +372,9 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Gets the latest preview version available. /// /// The product collection to search - /// InstallMode.SDK or InstallMode.Runtime + /// InstallComponent.SDK or InstallComponent.Runtime /// Latest preview version string, or null if none found - private string? GetLatestPreviewVersion(ProductCollection index, InstallMode mode) + private ReleaseVersion? GetLatestPreviewVersion(ProductCollection index, InstallComponent component) { // Get all products var allProducts = index.ToList(); @@ -413,7 +408,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } // Find latest version based on mode - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { var sdks = previewReleases .SelectMany(r => r.Sdks) @@ -423,7 +418,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (sdks.Any()) { - return sdks.First().Version.ToString(); + return sdks.First().Version; } } else // Runtime mode @@ -436,7 +431,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma if (runtimes.Any()) { - return runtimes.First().Version.ToString(); + return runtimes.First().Version; } } } @@ -445,7 +440,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } /// /// Gets the latest version for a major.minor channel (e.g., "9.0"). /// - private string? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallMode mode) + private ReleaseVersion? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallComponent component) { // Find the product for the requested major.minor string channelKey = $"{major}.{minor}"; @@ -460,7 +455,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); // Find the latest version based on mode - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { return GetLatestSdkVersion(releases, major, minor); } @@ -473,7 +468,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). /// - private string? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallMode mode) + private ReleaseVersion? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallComponent component) { // Find the product for the requested major.minor string channelKey = $"{major}.{minor}"; @@ -488,7 +483,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); // For SDK mode, use feature band filtering - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { return GetLatestFeatureBandVersion(releases, major, minor, featureBand); } @@ -571,9 +566,9 @@ private static string GetDefaultCacheDirectory() /// /// The .NET installation details /// The download URL for the installer/archive, or null if not found - public string? GetDownloadUrl(DotnetInstall install) + public string? GetDownloadUrl(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { - var targetFile = FindReleaseFile(install); + var targetFile = FindReleaseFile(installRequest, resolvedVersion); return targetFile?.Address.ToString(); } @@ -714,16 +709,16 @@ public bool DownloadArchive(string downloadUrl, string destinationPath, IProgres /// The local path to save the downloaded file /// Optional progress reporting /// True if download and verification were successful, false otherwise - public bool DownloadArchiveWithVerification(DotnetInstall install, string destinationPath, IProgress? progress = null) + public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) { // Get the download URL and expected hash - string? downloadUrl = GetDownloadUrl(install); + string? downloadUrl = GetDownloadUrl(installRequest, resolvedVersion); if (string.IsNullOrEmpty(downloadUrl)) { return false; } - string? expectedHash = GetArchiveHash(install); + string? expectedHash = GetArchiveHash(installRequest, resolvedVersion); if (string.IsNullOrEmpty(expectedHash)) { return false; @@ -742,18 +737,18 @@ public bool DownloadArchiveWithVerification(DotnetInstall install, string destin /// /// The .NET installation details /// The matching ReleaseFile, throws if none are available. - private ReleaseFile? FindReleaseFile(DotnetInstall install) + private ReleaseFile? FindReleaseFile(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { try { var productCollection = GetProductCollection(); - var product = FindProduct(productCollection, install.FullySpecifiedVersion.Value) ?? throw new InvalidOperationException($"No product found for version {install.FullySpecifiedVersion.MajorMinor}"); - var release = FindRelease(product, install.FullySpecifiedVersion.Value, install.Mode) ?? throw new InvalidOperationException($"No release found for version {install.FullySpecifiedVersion.Value}"); - return FindMatchingFile(release, install); + var product = FindProduct(productCollection, resolvedVersion) ?? throw new InvalidOperationException($"No product found for version {resolvedVersion}"); + var release = FindRelease(product, resolvedVersion, installRequest.Component) ?? throw new InvalidOperationException($"No release found for version {resolvedVersion}"); + return FindMatchingFile(release, installRequest, resolvedVersion); } catch (Exception ex) { - throw new InvalidOperationException($"Failed to find an available release for install {install} : ${ex.Message}"); + throw new InvalidOperationException($"Failed to find an available release for install {installRequest} : ${ex.Message}", ex); } } @@ -820,9 +815,8 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the product for the given version. /// - private static Product? FindProduct(ProductCollection productCollection, string version) + private static Product? FindProduct(ProductCollection productCollection, ReleaseVersion releaseVersion) { - var releaseVersion = new ReleaseVersion(version); var majorMinor = $"{releaseVersion.Major}.{releaseVersion.Minor}"; return productCollection.FirstOrDefault(p => p.ProductVersion == majorMinor); } @@ -830,16 +824,15 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the specific release for the given version. /// - private static ProductRelease? FindRelease(Product product, string version, InstallMode mode) + private static ProductRelease? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) { var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - var targetReleaseVersion = new ReleaseVersion(version); // Get all releases var allReleases = releases.ToList(); // First try to find the exact version in the original release list - var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(targetReleaseVersion)); + var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(resolvedVersion)); if (exactReleaseMatch != null) { return exactReleaseMatch; @@ -851,41 +844,30 @@ private static ProductCollection DeserializeProductCollection(string json) bool foundMatch = false; // Check the appropriate collection based on the mode - if (mode == InstallMode.SDK) + if (component == InstallComponent.SDK) { foreach (var sdk in release.Sdks) { // Check for exact match - if (sdk.Version.Equals(targetReleaseVersion)) + if (sdk.Version.Equals(resolvedVersion)) { foundMatch = true; break; } - // Check for match on major, minor, patch - if (sdk.Version.Major == targetReleaseVersion.Major && - sdk.Version.Minor == targetReleaseVersion.Minor && - sdk.Version.Patch == targetReleaseVersion.Patch) - { - foundMatch = true; - break; - } + // Not sure what the point of the below logic was + //// Check for match on major, minor, patch + //if (sdk.Version.Major == targetReleaseVersion.Major && + // sdk.Version.Minor == targetReleaseVersion.Minor && + // sdk.Version.Patch == targetReleaseVersion.Patch) + //{ + // foundMatch = true; + // break; + //} } } else // Runtime mode { - // Filter by runtime type based on file names in the release - var runtimeTypeMatches = release.Files.Any(f => - f.Name.Contains("runtime", StringComparison.OrdinalIgnoreCase) && - !f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase) && - !f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); - - var aspnetCoreMatches = release.Files.Any(f => - f.Name.Contains("aspnetcore", StringComparison.OrdinalIgnoreCase)); - - var windowsDesktopMatches = release.Files.Any(f => - f.Name.Contains("windowsdesktop", StringComparison.OrdinalIgnoreCase)); - // Get the appropriate runtime components based on the file patterns var filteredRuntimes = release.Runtimes; @@ -895,20 +877,20 @@ private static ProductCollection DeserializeProductCollection(string json) foreach (var runtime in filteredRuntimes) { // Check for exact match - if (runtime.Version.Equals(targetReleaseVersion)) + if (runtime.Version.Equals(resolvedVersion)) { foundMatch = true; break; } - // Check for match on major, minor, patch - if (runtime.Version.Major == targetReleaseVersion.Major && - runtime.Version.Minor == targetReleaseVersion.Minor && - runtime.Version.Patch == targetReleaseVersion.Patch) - { - foundMatch = true; - break; - } + //// Check for match on major, minor, patch + //if (runtime.Version.Major == targetReleaseVersion.Major && + // runtime.Version.Minor == targetReleaseVersion.Minor && + // runtime.Version.Patch == targetReleaseVersion.Patch) + //{ + // foundMatch = true; + // break; + //} } } @@ -924,14 +906,14 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the matching file in the release for the given installation requirements. /// - private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstall install) + private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { - var rid = DnupUtilities.GetRuntimeIdentifier(install.Architecture); + var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture); var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); // Determine the component type pattern to look for in file names string componentTypePattern; - if (install.Mode == InstallMode.SDK) + if (installRequest.Component == InstallComponent.SDK) { componentTypePattern = "sdk"; } @@ -942,12 +924,12 @@ private static ProductCollection DeserializeProductCollection(string json) componentTypePattern = "runtime"; // Check if this is specifically an ASP.NET Core runtime - if (install.FullySpecifiedVersion.Value.Contains("aspnetcore")) + if (installRequest.Component == InstallComponent.ASPNETCore) { componentTypePattern = "aspnetcore"; } // Check if this is specifically a Windows Desktop runtime - else if (install.FullySpecifiedVersion.Value.Contains("windowsdesktop")) + else if (installRequest.Component == InstallComponent.WindowsDesktop) { componentTypePattern = "windowsdesktop"; } @@ -966,7 +948,7 @@ private static ProductCollection DeserializeProductCollection(string json) } // If we have multiple matching files, prefer the one with the full version in the name - var versionString = install.FullySpecifiedVersion.Value; + var versionString = resolvedVersion.ToString(); var bestMatch = matchingFiles.FirstOrDefault(f => f.Name.Contains(versionString, StringComparison.OrdinalIgnoreCase)); // If no file has the exact version string, return the first match @@ -978,9 +960,9 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// The .NET installation details /// The SHA512 hash string of the installer/archive, or null if not found - public string? GetArchiveHash(DotnetInstall install) + public string? GetArchiveHash(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { - var targetFile = FindReleaseFile(install); + var targetFile = FindReleaseFile(installRequest, resolvedVersion); return targetFile?.Hash; } diff --git a/src/Installer/dnup/UpdateChannel.cs b/src/Installer/dnup/UpdateChannel.cs new file mode 100644 index 000000000000..fc25144ed5b0 --- /dev/null +++ b/src/Installer/dnup/UpdateChannel.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public class UpdateChannel + { + public string Name { get; set; } + + public UpdateChannel(string name) + { + Name = name; + } + + public bool IsFullySpecifiedVersion() + { + return ReleaseVersion.TryParse(Name, out _); + } + + } +} diff --git a/test/dnup.Tests/DotnetInstallTests.cs b/test/dnup.Tests/DotnetInstallTests.cs deleted file mode 100644 index b0c497b8e9a1..000000000000 --- a/test/dnup.Tests/DotnetInstallTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Tools.Bootstrapper; - -namespace Microsoft.DotNet.Tools.Dnup.Tests; - -public class DotnetInstallTests -{ - [Fact] - public void DotnetInstallBase_ShouldInitializeCorrectly() - { - var directory = "/test/directory"; - var type = InstallType.User; - var mode = InstallMode.SDK; - var architecture = InstallArchitecture.x64; - - var install = new DotnetInstallBase(directory, type, mode, architecture); - - install.ResolvedDirectory.Should().Be(directory); - install.Type.Should().Be(type); - install.Mode.Should().Be(mode); - install.Architecture.Should().Be(architecture); - install.Id.Should().NotBe(Guid.Empty); - } - - [Fact] - public void DotnetInstall_ShouldInheritFromBase() - { - var version = "8.0.301"; - var directory = "/test/directory"; - var type = InstallType.User; - var mode = InstallMode.SDK; - var architecture = InstallArchitecture.x64; - - var install = new DotnetInstall(new DotnetVersion(version), directory, type, mode, architecture, new ManagementCadence()); - - install.FullySpecifiedVersion.Value.Should().Be(version); - install.ResolvedDirectory.Should().Be(directory); - install.Type.Should().Be(type); - install.Mode.Should().Be(mode); - install.Architecture.Should().Be(architecture); - install.Id.Should().NotBe(Guid.Empty); - } - - [Fact] - public void MultipleInstances_ShouldHaveUniqueIds() - { - // Arrange & Act - var install1 = new DotnetInstallBase("dir1", InstallType.User, InstallMode.SDK, InstallArchitecture.x64); - var install2 = new DotnetInstallBase("dir2", InstallType.Admin, InstallMode.Runtime, InstallArchitecture.x64); - - // Assert - install1.Id.Should().NotBe(install2.Id); - } - - [Fact] - public void Records_ShouldSupportValueEquality() - { - // Arrange - var install1 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); - var install2 = new DotnetInstall("8.0.301", "/test", InstallType.User, InstallMode.SDK, InstallArchitecture.x64, new ManagementCadence()); - - // Act & Assert - // Records should be equal based on values, except for the Id which is always unique - install1.FullySpecifiedVersion.Should().Be(install2.FullySpecifiedVersion); - install1.ResolvedDirectory.Should().Be(install2.ResolvedDirectory); - install1.Type.Should().Be(install2.Type); - install1.Mode.Should().Be(install2.Mode); - install1.Architecture.Should().Be(install2.Architecture); - - // But Ids should be different - install1.Id.Should().NotBe(install2.Id); - } -} From e3be31b6b11d2bf57c90fa51a24409fe5ec7537e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 7 Oct 2025 12:38:18 -0400 Subject: [PATCH 060/153] Add library for installer functionality --- dnup.slnf | 1 + sdk.slnx | 3 ++- .../Microsoft.Dotnet.Installation.csproj | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj diff --git a/dnup.slnf b/dnup.slnf index d68938fd2ae0..b38b36feec3d 100644 --- a/dnup.slnf +++ b/dnup.slnf @@ -3,6 +3,7 @@ "path": "sdk.slnx", "projects": [ "src\\Installer\\dnup\\dnup.csproj", + "src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj", "test\\dnup.Tests\\dnup.Tests.csproj", ] } diff --git a/sdk.slnx b/sdk.slnx index 2ea3064bed5f..62e668dc5c4f 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -87,6 +87,7 @@ + @@ -289,12 +290,12 @@ + - diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj new file mode 100644 index 000000000000..4b8baf27e21d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + true + + + + + + + From ea1932d4f340167a017bf33bf4b69069e9e284a7 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 7 Oct 2025 17:10:36 -0400 Subject: [PATCH 061/153] Fix json serialization after refactor --- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 2abd4eb49b68..937f1d2112f7 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -11,7 +11,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper [JsonSerializable(typeof(List))] [JsonSerializable(typeof(DotnetVersion))] [JsonSerializable(typeof(DotnetVersionType))] - [JsonSerializable(typeof(InstallMode))] + [JsonSerializable(typeof(InstallComponent))] [JsonSerializable(typeof(InstallArchitecture))] [JsonSerializable(typeof(InstallType))] [JsonSerializable(typeof(ManagementCadence))] From 41e88dd015e8ead4c38bc86115567e5b9ee8bd6c Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 7 Oct 2025 17:14:35 -0400 Subject: [PATCH 062/153] Add initial interfaces to installation library --- .../DotnetInstallRoot.cs | 15 +++++++++++++++ .../IDotnetInstallDiscoverer.cs | 14 ++++++++++++++ .../IDotnetInstaller.cs | 11 +++++++++++ .../IDotnetReleaseInfoProvider.cs | 16 ++++++++++++++++ .../InstallArchitecture.cs | 13 ++++++------- .../Microsoft.Dotnet.Installation/InstallType.cs | 13 +++++++++++++ src/Installer/dnup/DotnetInstall.cs | 9 +-------- src/Installer/dnup/IChannelVersionResolver.cs | 12 ------------ src/Installer/dnup/InstallType.cs | 14 -------------- src/Installer/dnup/dnup.csproj | 10 +++++++++- 10 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs create mode 100644 src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs create mode 100644 src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs create mode 100644 src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs rename src/Installer/{dnup => Microsoft.Dotnet.Installation}/InstallArchitecture.cs (61%) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/InstallType.cs delete mode 100644 src/Installer/dnup/IChannelVersionResolver.cs delete mode 100644 src/Installer/dnup/InstallType.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs new file mode 100644 index 000000000000..c24738febb9d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; +public record DotnetInstallRoot( + string? Path, + InstallType Type, + InstallArchitecture Architecture) +{ + // Do we need a GUID for the ID here? +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs new file mode 100644 index 000000000000..82627a0da86e --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +internal interface IDotnetInstallDiscoverer +{ + // List installed SDKs under an install root + +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs new file mode 100644 index 000000000000..2467ff0566ef --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +public interface IDotnetInstaller +{ + // Install a version of the SDK + // Uninstall a version of the SDK +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs new file mode 100644 index 000000000000..a57008837c98 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +public class IDotnetReleaseInfoProvider +{ + // Get available release channels + // Get latest version in a channel + // Get all versions in a channel + // Get support level for a version / channel? +} diff --git a/src/Installer/dnup/InstallArchitecture.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs similarity index 61% rename from src/Installer/dnup/InstallArchitecture.cs rename to src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs index a046bf3d1721..ddc1b5b93d0e 100644 --- a/src/Installer/dnup/InstallArchitecture.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs @@ -5,12 +5,11 @@ using System.Collections.Generic; using System.Text; -namespace Microsoft.DotNet.Tools.Bootstrapper +namespace Microsoft.Dotnet.Installation; + +public enum InstallArchitecture { - public enum InstallArchitecture - { - x86, - x64, - arm64 - } + x86, + x64, + arm64 } diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallType.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallType.cs new file mode 100644 index 000000000000..78c52942cecc --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallType.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation; + +public enum InstallType +{ + None, + // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to + Inconsistent, + Admin, + User +} diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 470b73f530b8..3dadd48884ef 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -3,17 +3,10 @@ using System; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Bootstrapper; -public record DotnetInstallRoot( - string? Path, - InstallType Type, - InstallArchitecture Architecture) -{ - // Do we need a GUID for the ID here? -} - /// /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. diff --git a/src/Installer/dnup/IChannelVersionResolver.cs b/src/Installer/dnup/IChannelVersionResolver.cs deleted file mode 100644 index f0cfb323f0f5..000000000000 --- a/src/Installer/dnup/IChannelVersionResolver.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - internal interface ChannelVersionResolver - { - public DotnetInstall Resolve(DotnetInstallRequest dotnetChannelVersion); - } -} diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs deleted file mode 100644 index 065b520e7e6b..000000000000 --- a/src/Installer/dnup/InstallType.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - public enum InstallType - { - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, - Admin, - User - } -} diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index 3d343aae531e..5ef732fe7e6e 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -34,7 +34,15 @@ - + + + + + + + + + From d0572baff781cb5a6fd1637c2fd0f57411ae2a52 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Thu, 9 Oct 2025 11:03:04 -0400 Subject: [PATCH 063/153] Flesh out installer interfaces --- .../DotnetInstallRoot.cs | 5 +---- .../IDotnetInstallDiscoverer.cs | 4 ++-- .../IDotnetInstaller.cs | 5 +++-- .../IDotnetReleaseInfoProvider.cs | 22 ++++++++++++++----- .../InstallComponent.cs | 12 ++++++++++ src/Installer/dnup/InstallComponent.cs | 14 ------------ 6 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs delete mode 100644 src/Installer/dnup/InstallComponent.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs index c24738febb9d..fe2e13bbfea0 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs @@ -9,7 +9,4 @@ namespace Microsoft.Dotnet.Installation; public record DotnetInstallRoot( string? Path, InstallType Type, - InstallArchitecture Architecture) -{ - // Do we need a GUID for the ID here? -} + InstallArchitecture Architecture); diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs index 82627a0da86e..351ebfccb45a 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs @@ -4,11 +4,11 @@ using System; using System.Collections.Generic; using System.Text; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.Dotnet.Installation; internal interface IDotnetInstallDiscoverer { - // List installed SDKs under an install root - + IEnumerable ListInstalledVersions(DotnetInstallRoot dotnetRoot, InstallComponent component); } diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs index 2467ff0566ef..04eff3cf325a 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using System.Text; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.Dotnet.Installation; public interface IDotnetInstaller { - // Install a version of the SDK - // Uninstall a version of the SDK + void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version); + void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version); } diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs index a57008837c98..3f5e2e4d95a3 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs @@ -4,13 +4,25 @@ using System; using System.Collections.Generic; using System.Text; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.Dotnet.Installation; -public class IDotnetReleaseInfoProvider +public interface IDotnetReleaseInfoProvider { - // Get available release channels - // Get latest version in a channel - // Get all versions in a channel - // Get support level for a version / channel? + IEnumerable GetAvailableChannels(); + + ReleaseVersion GetLatestVersion(InstallComponent component, string channel); + + // Get all versions in a channel - do we have a scenario for this? + //IEnumerable GetAllVersions(InstallComponent component, string channel); + + SupportType GetSupportType(InstallComponent component, ReleaseVersion version); +} + +public enum SupportType +{ + OutOfSupport, + LongTermSupport, + StandardTermSupport } diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs new file mode 100644 index 000000000000..1cecbe90e59d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation; + +public enum InstallComponent +{ + SDK, + Runtime, + ASPNETCore, + WindowsDesktop +} diff --git a/src/Installer/dnup/InstallComponent.cs b/src/Installer/dnup/InstallComponent.cs deleted file mode 100644 index a65f29c987b5..000000000000 --- a/src/Installer/dnup/InstallComponent.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - public enum InstallComponent - { - SDK, - Runtime, - ASPNETCore, - WindowsDesktop - } - -} From cbbfb22ccfe595d2b189fee1b3a699b899d98893 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 10 Oct 2025 11:13:36 -0400 Subject: [PATCH 064/153] Update install root, install discoverer, and install type APIs --- .../DotnetInstallRoot.cs | 3 +- .../IDotnetInstallDiscoverer.cs | 6 +- src/Installer/dnup/BootstrapperController.cs | 35 +++------- .../EnvironmentVariableMockDotnetInstaller.cs | 10 +-- ...ironmentVariableMockReleaseInfoProvider.cs | 32 ++++++--- .../Commands/Sdk/Install/SdkInstallCommand.cs | 23 +++---- src/Installer/dnup/IBootstrapperController.cs | 12 ++-- .../InstallType.cs | 5 +- test/dnup.Tests/ReleaseManifestTests.cs | 67 +++++++++---------- 9 files changed, 89 insertions(+), 104 deletions(-) rename src/Installer/{Microsoft.Dotnet.Installation => dnup}/InstallType.cs (62%) diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs index fe2e13bbfea0..0deeb807808d 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs @@ -7,6 +7,5 @@ namespace Microsoft.Dotnet.Installation; public record DotnetInstallRoot( - string? Path, - InstallType Type, + string Path, InstallArchitecture Architecture); diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs index 351ebfccb45a..64c1e859f6ce 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs @@ -8,7 +8,9 @@ namespace Microsoft.Dotnet.Installation; -internal interface IDotnetInstallDiscoverer +public interface IDotnetInstallDiscoverer { - IEnumerable ListInstalledVersions(DotnetInstallRoot dotnetRoot, InstallComponent component); + DotnetInstallRoot GetDotnetInstallRootFromPath(); + + IEnumerable GetInstalledVersions(DotnetInstallRoot dotnetRoot, InstallComponent component); } diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index 3e7a6b1e888a..ffc4ddc7f030 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -19,13 +19,13 @@ public BootstrapperController(IEnvironmentProvider? environmentProvider = null) _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } - public DotnetInstallRoot GetConfiguredInstallType() + public DotnetInstallRootConfiguration? GetConfiguredInstallType() { string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); if (string.IsNullOrEmpty(foundDotnet)) { - return new(null, InstallType.None, DnupUtilities.GetDefaultInstallArchitecture()); + return null; } string installDir = Path.GetDirectoryName(foundDotnet)!; @@ -36,27 +36,12 @@ public DotnetInstallRoot GetConfiguredInstallType() string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); - - if (isAdminInstall) - { - // Admin install: DOTNET_ROOT should not be set, or if set, should match installDir - if (!string.IsNullOrEmpty(dotnetRoot) && !DnupUtilities.PathsEqual(dotnetRoot, installDir) && - !dotnetRoot.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) && - !dotnetRoot.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) - { - return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); - } - return new(installDir, InstallType.Admin, DnupUtilities.GetDefaultInstallArchitecture()); - } - else - { - // User install: DOTNET_ROOT must be set and match installDir - if (string.IsNullOrEmpty(dotnetRoot) || !DnupUtilities.PathsEqual(dotnetRoot, installDir)) - { - return new(installDir, InstallType.Inconsistent, DnupUtilities.GetDefaultInstallArchitecture()); - } - return new(installDir, InstallType.User, DnupUtilities.GetDefaultInstallArchitecture()); - } + + var installRoot = new DotnetInstallRoot(installDir, DnupUtilities.GetDefaultInstallArchitecture()); + + bool isSetAsDotnetRoot = DnupUtilities.PathsEqual(dotnetRoot, installDir); + + return new(installRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsOnPath: true, isSetAsDotnetRoot); } public string GetDefaultDotnetInstallPath() @@ -153,10 +138,6 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); break; - case InstallType.None: - // Unset DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); - break; default: throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs index 4617d65eabe8..80b332521b4d 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -27,16 +27,16 @@ public string GetDefaultDotnetInstallPath() return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); } - public DotnetInstallRoot GetConfiguredInstallType() + public DotnetInstallRootConfiguration? GetConfiguredInstallType() { var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); - InstallType installtype = InstallType.None; + InstallType installtype; if (!Enum.TryParse(testHookDefaultInstall, out installtype)) { - installtype = InstallType.None; + return null; } - var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH"); - return new(installPath, installtype, DnupUtilities.GetDefaultInstallArchitecture()); + var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH") ?? GetDefaultDotnetInstallPath(); + return new(new(installPath, DnupUtilities.GetDefaultInstallArchitecture()), installtype, true, true); } public string? GetLatestInstalledAdminVersion() diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs index 681e0d40ac0d..29c71f70f56e 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.Deployment.DotNet.Releases; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install { - internal class EnvironmentVariableMockReleaseInfoProvider : IReleaseInfoProvider + internal class EnvironmentVariableMockReleaseInfoProvider : IDotnetReleaseInfoProvider { - public List GetAvailableChannels() + IEnumerable IDotnetReleaseInfoProvider.GetAvailableChannels() { var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); if (string.IsNullOrEmpty(channels)) @@ -16,34 +17,45 @@ public List GetAvailableChannels() } return channels.Split(',').ToList(); } - public string GetLatestVersion(string channel) + public ReleaseVersion GetLatestVersion(InstallComponent component, string channel) { + if (component != InstallComponent.SDK) + { + throw new NotImplementedException("Only SDK component is supported in this mock provider"); + } + + string version; if (channel == "preview") { - return "11.0.100-preview.1.42424"; + version = "11.0.100-preview.1.42424"; } else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") { - return "10.0.0-preview.7"; + version = "10.0.0-preview.7"; } else if (channel == "10.0.1xx") { - return "10.0.106"; + version = "10.0.106"; } else if (channel == "9" || channel == "9.0.3xx") { - return "9.0.309"; + version = "9.0.309"; } else if (channel == "9.0.2xx") { - return "9.0.212"; + version = "9.0.212"; } else if (channel == "9.0.1xx") { - return "9.0.115"; + version = "9.0.115"; } - return channel; + version = channel; + + return new ReleaseVersion(version); } + + public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 888f0f65e689..a4f33e9aa6a7 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -22,7 +22,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); - private readonly IReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); + private readonly IDotnetReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); public override int Execute() @@ -54,7 +54,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && currentDotnetInstallRoot.Type == InstallType.User) + if (resolvedInstallPath == null && currentDotnetInstallRoot != null && currentDotnetInstallRoot.InstallType == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentDotnetInstallRoot.Path; @@ -132,13 +132,13 @@ public override int Execute() // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_interactive && installPathFromGlobalJson == null) { - if (currentDotnetInstallRoot.Type == InstallType.None) + if (currentDotnetInstallRoot == null) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (currentDotnetInstallRoot.Type == InstallType.User) + else if (currentDotnetInstallRoot.InstallType == InstallType.User) { if (DnupUtilities.PathsEqual(resolvedInstallPath, currentDotnetInstallRoot.Path)) { @@ -151,7 +151,7 @@ public override int Execute() defaultValue: false); } } - else if (currentDotnetInstallRoot.Type == InstallType.Admin) + else if (currentDotnetInstallRoot.InstallType == InstallType.Admin) { SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentDotnetInstallRoot.Path}. We can configure your system to use the new install of .NET " + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); @@ -160,11 +160,8 @@ public override int Execute() $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", defaultValue: true); } - else if (currentDotnetInstallRoot.Type == InstallType.Inconsistent) - { - // TODO: Figure out what to do here - resolvedSetDefaultInstall = false; - } + + // TODO: Add checks for whether PATH and DOTNET_ROOT need to be updated, or if the install is in an inconsistent state } else { @@ -176,14 +173,14 @@ public override int Execute() // Create a request and resolve it using the channel version resolver var installRequest = new DotnetInstallRequest( - new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), new UpdateChannel(resolvedChannel), InstallComponent.SDK, new InstallRequestOptions()); var resolvedVersion = _channelVersionResolver.Resolve(installRequest); - if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot.Type == InstallType.Admin) + if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot?.InstallType == InstallType.Admin) { if (_interactive) { @@ -227,7 +224,7 @@ public override int Execute() { // Create the request for the additional version var additionalRequest = new DotnetInstallRequest( - new DotnetInstallRoot(resolvedInstallPath, InstallType.User, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), new UpdateChannel(additionalVersion), InstallComponent.SDK, new InstallRequestOptions()); diff --git a/src/Installer/dnup/IBootstrapperController.cs b/src/Installer/dnup/IBootstrapperController.cs index be4d7760352b..5b9752c664f5 100644 --- a/src/Installer/dnup/IBootstrapperController.cs +++ b/src/Installer/dnup/IBootstrapperController.cs @@ -14,7 +14,7 @@ public interface IBootstrapperController string GetDefaultDotnetInstallPath(); - DotnetInstallRoot GetConfiguredInstallType(); + DotnetInstallRootConfiguration? GetConfiguredInstallType(); string? GetLatestInstalledAdminVersion(); @@ -39,8 +39,12 @@ public class GlobalJsonInfo public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths != null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; } -public interface IReleaseInfoProvider +public record DotnetInstallRootConfiguration( + DotnetInstallRoot InstallRoot, + InstallType InstallType, + bool IsOnPath, + // We may also need additional information to handle the case of whether DOTNET_ROOT is not set or whether it's set to a different path + bool IsSetAsDotnetRoot) { - List GetAvailableChannels(); - string GetLatestVersion(string channel); + public string Path => InstallRoot.Path; } diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallType.cs b/src/Installer/dnup/InstallType.cs similarity index 62% rename from src/Installer/Microsoft.Dotnet.Installation/InstallType.cs rename to src/Installer/dnup/InstallType.cs index 78c52942cecc..c63605e859f7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/InstallType.cs +++ b/src/Installer/dnup/InstallType.cs @@ -5,9 +5,6 @@ namespace Microsoft.Dotnet.Installation; public enum InstallType { - None, - // Inconsistent would be when the dotnet on the path doesn't match what DOTNET_ROOT is set to - Inconsistent, + User, Admin, - User } diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 81e88633c8dd..5bc665138b18 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -1,6 +1,7 @@ using System; using Xunit; using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Dnup.Tests { @@ -10,17 +11,17 @@ public class ReleaseManifestTests public void GetLatestVersionForChannel_MajorOnly_ReturnsLatestVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("9", InstallMode.SDK); - Assert.True(!string.IsNullOrEmpty(version)); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9"), InstallComponent.SDK); + Assert.NotNull(version); } [Fact] public void GetLatestVersionForChannel_MajorMinor_ReturnsLatestVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("9.0", InstallMode.SDK); - Assert.False(string.IsNullOrEmpty(version)); - Assert.StartsWith("9.0.", version); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9"), InstallComponent.SDK); + Assert.NotNull(version); + Assert.StartsWith("9.0.", version.ToString()); } [Fact] @@ -28,78 +29,70 @@ public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("9.0.1xx", InstallMode.SDK); - Console.WriteLine($"Version found: {version ?? "null"}"); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9.0.1xx"), InstallComponent.SDK); + Console.WriteLine($"Version found: {version}"); // Feature band version should be returned in the format 9.0.100 - Assert.True(!string.IsNullOrEmpty(version)); - Assert.Matches(@"^9\.0\.1\d{2}$", version); + Assert.NotNull(version); + Assert.Matches(@"^9\.0\.1\d{2}$", version.ToString()); } [Fact] public void GetLatestVersionForChannel_LTS_ReturnsLatestLTSVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("lts", InstallMode.SDK); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("lts"), InstallComponent.SDK); - Console.WriteLine($"LTS Version found: {version ?? "null"}"); + Console.WriteLine($"LTS Version found: {version}"); // Check that we got a version - Assert.False(string.IsNullOrEmpty(version)); + Assert.NotNull(version); - // LTS versions should have even minor versions (e.g., 6.0, 8.0, 10.0) - var versionParts = version.Split('.'); - Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); - - int minorVersion = int.Parse(versionParts[1]); - Assert.True(minorVersion % 2 == 0, $"LTS version {version} should have an even minor version"); + // LTS versions should have even major versions (e.g., 6.0, 8.0, 10.0) + Assert.True(version.Minor % 2 == 0, $"LTS version {version} should have an even minor version"); // Should not be a preview version - Assert.DoesNotContain("-", version); + Assert.Null(version.Prerelease); } [Fact] public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("sts", InstallMode.SDK); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("sts"), InstallComponent.SDK); - Console.WriteLine($"STS Version found: {version ?? "null"}"); + Console.WriteLine($"STS Version found: {version}"); // Check that we got a version - Assert.False(string.IsNullOrEmpty(version)); - - // STS versions should have odd minor versions (e.g., 7.0, 9.0, 11.0) - var versionParts = version.Split('.'); - Assert.True(versionParts.Length >= 2, "Version should have at least major.minor parts"); + Assert.NotNull(version); - int minorVersion = int.Parse(versionParts[1]); - Assert.True(minorVersion % 2 != 0, $"STS version {version} should have an odd minor version"); + // STS versions should have odd major versions (e.g., 7.0, 9.0, 11.0) + Assert.True(version.Major % 2 != 0, $"STS version {version} should have an odd minor version"); // Should not be a preview version - Assert.DoesNotContain("-", version); + Assert.Null(version.Prerelease); } [Fact] public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() { var manifest = new ReleaseManifest(); - var version = manifest.GetLatestVersionForChannel("preview", InstallMode.SDK); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("preview"), InstallComponent.SDK); - Console.WriteLine($"Preview Version found: {version ?? "null"}"); + Console.WriteLine($"Preview Version found: {version}"); // Check that we got a version - Assert.False(string.IsNullOrEmpty(version)); + Assert.NotNull(version); // Preview versions should contain a hyphen (e.g., "11.0.0-preview.1") - Assert.Contains("-", version); + Assert.NotNull(version.Prerelease); // Should contain preview, rc, beta, or alpha Assert.True( - version.Contains("preview", StringComparison.OrdinalIgnoreCase) || - version.Contains("rc", StringComparison.OrdinalIgnoreCase) || - version.Contains("beta", StringComparison.OrdinalIgnoreCase) || - version.Contains("alpha", StringComparison.OrdinalIgnoreCase), + version.Prerelease.Contains("preview", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("rc", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("beta", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("alpha", StringComparison.OrdinalIgnoreCase), $"Version {version} should be a preview/rc/beta/alpha version" ); } From c20e563bb7474587e4364edb9440e62461357857 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 16 Oct 2025 16:08:35 -0700 Subject: [PATCH 065/153] Move the launching workspace into a root folder for the lib and dnup --- src/Installer/dnup/dnup.code-workspace | 78 ------------- src/Installer/installer.code-workspace | 153 ++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 83 deletions(-) delete mode 100644 src/Installer/dnup/dnup.code-workspace diff --git a/src/Installer/dnup/dnup.code-workspace b/src/Installer/dnup/dnup.code-workspace deleted file mode 100644 index 6e8de4bb18ae..000000000000 --- a/src/Installer/dnup/dnup.code-workspace +++ /dev/null @@ -1,78 +0,0 @@ -{ - "folders": [ - { - "path": ".", - "name": "dnup" - }, - { - "path": "../../../test/dnup.Tests", - "name": "dnup.Tests" - } - ], - "settings": { - "dotnet.defaultSolution": "dnup.csproj", - "omnisharp.defaultLaunchSolution": "dnup.csproj", - "csharp.debug.console": "externalTerminal", - "editor.formatOnSave": true, - "omnisharp.enableRoslynAnalyzers": true, - "omnisharp.useModernNet": true - }, - "launch": { - "version": "0.2.0", - "configurations": [ - { - "name": "Launch dnup (Default)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", - "args": [ - "sdk", - "install" - ], - "cwd": "${workspaceFolder}", - "console": "externalTerminal", - "stopAtEntry": false, - "logging": { - "moduleLoad": false - } - } - ], - "compounds": [] - }, - "tasks": { - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "type": "process", - "command": "dotnet", - "args": [ - "build", - "dnup.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile", - "group": { - "kind": "build", - "isDefault": true - } - }, - { - "label": "test", - "type": "process", - "command": "dotnet", - "args": [ - "test", - "../../../../test/dnup.Tests/dnup.Tests.csproj" - ], - "problemMatcher": "$msCompile", - "group": { - "kind": "test", - "isDefault": true - } - } - ] - } -} \ No newline at end of file diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index dcf51a098081..e168d94f945f 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -1,8 +1,151 @@ { - "folders": [ - { - "path": "dnup" + "folders": [ + { + "path": ".", + "name": "installer" + }, + { + "path": "../../test/dnup.Tests", + "name": "dnup.Tests" + } + ], + "settings": { + "dotnet.defaultSolution": "dnup/dnup.csproj", + "csharp.debug.console": "integratedTerminal", + "editor.formatOnSave": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder:installer}/../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder:installer}/dnup", + "console": "integratedTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + }, + { + "name": "Run dnup tests (launch)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "--no-build", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "cwd": "${workspaceFolder:dnup.Tests}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ], + "compounds": [] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "dnup/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "options": { + "cwd": "${workspaceFolder:installer}" + }, + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": true + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "type": "process", + "command": "dotnet", + "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "options": { + "cwd": "${workspaceFolder:installer}" + }, + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": true + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + }, + { + "label": "test-watch", + "type": "process", + "command": "dotnet", + "args": [ + "watch", + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "options": { + "cwd": "${workspaceFolder:installer}" + }, + "presentation": { + "reveal": "always", + "panel": "new", + "showReuseMessage": true + }, + "isBackground": true, + "problemMatcher": "$msCompile" + } + ] } - ], - "settings": {} } From 3a962244c9301fb942c8c124fba6ee86612823f5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 16 Oct 2025 16:28:42 -0700 Subject: [PATCH 066/153] Use a clear, empty terminal window for launching rerun --- src/Installer/installer.code-workspace | 36 +++++--------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index e168d94f945f..e3c519fccb84 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -12,6 +12,7 @@ "settings": { "dotnet.defaultSolution": "dnup/dnup.csproj", "csharp.debug.console": "integratedTerminal", + "debug.terminal.clearBeforeReusing": true, "editor.formatOnSave": true, "omnisharp.useModernNet": true }, @@ -82,7 +83,8 @@ "presentation": { "reveal": "always", "panel": "shared", - "showReuseMessage": true + "showReuseMessage": false, + "clear": true }, "problemMatcher": "$msCompile", "group": { @@ -111,7 +113,8 @@ "presentation": { "reveal": "always", "panel": "shared", - "showReuseMessage": true + "showReuseMessage": false, + "clear": true }, "problemMatcher": "$msCompile", "group": { @@ -119,33 +122,6 @@ "isDefault": true } }, - { - "label": "test-watch", - "type": "process", - "command": "dotnet", - "args": [ - "watch", - "test", - "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary", - "/p:ContinuousIntegrationBuild=false", - "/p:UseSharedCompilation=true", - "/p:Deterministic=false", - "/p:RunAnalyzers=false", - "/p:NoRestore=true" - ], - "options": { - "cwd": "${workspaceFolder:installer}" - }, - "presentation": { - "reveal": "always", - "panel": "new", - "showReuseMessage": true - }, - "isBackground": true, - "problemMatcher": "$msCompile" - } ] } -} +} \ No newline at end of file From a2f01a27447884582675e1c889518365847255a1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 16 Oct 2025 16:40:45 -0700 Subject: [PATCH 067/153] Add entire SDK repo to workspace so it's easier to work with both contexts. --- src/Installer/installer.code-workspace | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index e3c519fccb84..2d4e9f997dfb 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -7,6 +7,9 @@ { "path": "../../test/dnup.Tests", "name": "dnup.Tests" + }, + { + "path": "../.." } ], "settings": { From 61875c1ad06880783c3b59040da1e94b277acaf2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 16 Oct 2025 17:26:35 -0700 Subject: [PATCH 068/153] wip - prepare for e2e test --- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- src/Installer/dnup/DnupSharedManifest.cs | 18 ++++- src/Installer/dnup/ReleaseManifest.cs | 78 ++++++++++++++++++- .../dnup/ReleaseVersionJsonConverter.cs | 28 +++++++ test/dnup.Tests/DnupEndToEndCollection.cs | 11 +++ 5 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 src/Installer/dnup/ReleaseVersionJsonConverter.cs create mode 100644 test/dnup.Tests/DnupEndToEndCollection.cs diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 937f1d2112f7..13e5a6ff3bff 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - Converters = new[] { typeof(DotnetVersionJsonConverter) })] + Converters = new[] { typeof(DotnetVersionJsonConverter), typeof(ReleaseVersionJsonConverter) })] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(DotnetVersion))] [JsonSerializable(typeof(DotnetVersionType))] diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index a1b11a869bfa..62ef6aee00b8 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -12,9 +12,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; internal class DnupSharedManifest : IDnupManifest { - private static readonly string ManifestPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "dnup", "dnup_manifest.json"); + private static string ManifestPath => GetManifestPath(); public DnupSharedManifest() { @@ -30,6 +28,20 @@ private void EnsureManifestExists() } } + private static string GetManifestPath() + { + var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); + if (!string.IsNullOrEmpty(overridePath)) + { + return overridePath; + } + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "dnup", + "dnup_manifest.json"); + } + private void AssertHasFinalizationMutex() { var mutex = Mutex.OpenExisting(Constants.MutexNames.ModifyInstallationStates); diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/dnup/ReleaseManifest.cs index 4909cadb65cf..5bb2e8ae002b 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/dnup/ReleaseManifest.cs @@ -223,7 +223,14 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Handle Preview channel - get the latest preview version var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); return GetLatestPreviewVersion(productIndex, component); - } // Parse the channel string into components + } + else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) + { + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestStableVersion(productIndex, component); + } + + // Parse the channel string into components var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -437,9 +444,72 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } return null; // No preview versions found - } /// - /// Gets the latest version for a major.minor channel (e.g., "9.0"). - /// + } + + /// + /// Gets the latest stable version across all available products. + /// + private ReleaseVersion? GetLatestStableVersion(ProductCollection index, InstallComponent component) + { + var sortedProducts = index + .OrderByDescending(p => + { + var productParts = p.ProductVersion.Split('.'); + if (productParts.Length > 0 && int.TryParse(productParts[0], out var major)) + { + var minor = productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) + ? minorVersion + : 0; + return major * 100 + minor; + } + return 0; + }) + .ToList(); + + foreach (var product in sortedProducts) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + var stableReleases = releases.Where(r => !r.IsPreview).ToList(); + + if (!stableReleases.Any()) + { + continue; + } + + if (component == InstallComponent.SDK) + { + var sdks = stableReleases + .SelectMany(r => r.Sdks) + .Where(sdk => !sdk.Version.ToString().Contains("-")) + .OrderByDescending(sdk => sdk.Version) + .ToList(); + + if (sdks.Any()) + { + return sdks.First().Version; + } + } + else + { + var runtimes = stableReleases + .SelectMany(r => r.Runtimes) + .Where(runtime => !runtime.Version.ToString().Contains("-")) + .OrderByDescending(runtime => runtime.Version) + .ToList(); + + if (runtimes.Any()) + { + return runtimes.First().Version; + } + } + } + + return null; + } + + /// + /// Gets the latest version for a major.minor channel (e.g., "9.0"). + /// private ReleaseVersion? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallComponent component) { // Find the product for the requested major.minor diff --git a/src/Installer/dnup/ReleaseVersionJsonConverter.cs b/src/Installer/dnup/ReleaseVersionJsonConverter.cs new file mode 100644 index 000000000000..3e20fa619541 --- /dev/null +++ b/src/Installer/dnup/ReleaseVersionJsonConverter.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal sealed class ReleaseVersionJsonConverter : JsonConverter +{ + public override ReleaseVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + if (string.IsNullOrEmpty(value)) + { + throw new JsonException("ReleaseVersion value cannot be null or empty."); + } + + return new ReleaseVersion(value); + } + + public override void Write(Utf8JsonWriter writer, ReleaseVersion value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/test/dnup.Tests/DnupEndToEndCollection.cs b/test/dnup.Tests/DnupEndToEndCollection.cs new file mode 100644 index 000000000000..921250737806 --- /dev/null +++ b/test/dnup.Tests/DnupEndToEndCollection.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +[CollectionDefinition("DnupEndToEndCollection", DisableParallelization = true)] +public class DnupEndToEndCollectionDefinition +{ +} From 091bc07c899644725fb153ce2d6768f06040eced Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 14:46:43 -0700 Subject: [PATCH 069/153] Add copilot instructions to use powershell over cmd for dnup --- src/Installer/.github/copilot-instructions.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/Installer/.github/copilot-instructions.md diff --git a/src/Installer/.github/copilot-instructions.md b/src/Installer/.github/copilot-instructions.md new file mode 100644 index 000000000000..edc9547ec6c2 --- /dev/null +++ b/src/Installer/.github/copilot-instructions.md @@ -0,0 +1,8 @@ + +--- +applyTo: "**" +--- +- Environment: Windows 11 using PowerShell 7 +- Never use `&&` to chain commands; use semicolon (`;`) for PowerShell command chaining +- Prefer PowerShell cmdlets over external utilities when available +- Use PowerShell-style parameter syntax (-Parameter) rather than Unix-style flags From 0d0fff293b75ceed3f243111dc71a1cfd70471c5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 14:47:56 -0700 Subject: [PATCH 070/153] Add --no-progress because spectre prevents concurrent interactive displays. --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 22 ++++++++++++++----- .../Sdk/Install/SdkInstallCommandParser.cs | 4 +++- .../Sdk/Update/SdkUpdateCommandParser.cs | 4 +++- src/Installer/dnup/CommonOptions.cs | 5 +++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index a4f33e9aa6a7..227927524687 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -20,6 +20,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool? _setDefaultInstall = result.GetValue(SdkInstallCommandParser.SetDefaultInstallOption); private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); + private readonly bool _noProgress = result.GetValue(SdkInstallCommandParser.NoProgressOption); private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); private readonly IDotnetReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); @@ -207,11 +208,22 @@ public override int Execute() SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - // Create and use a progress context - var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); + DotnetInstall? mainInstall; - // Install the main SDK using the InstallerOrchestratorSingleton directly - DotnetInstall? mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); + // In no-progress mode, install directly without using a progress display + if (_noProgress) + { + // Install without progress display + mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); + } + else + { + // Create and use a progress context + var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); + + // Install the main SDK using the InstallerOrchestratorSingleton directly + mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); + } if (mainInstall == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); @@ -229,7 +241,7 @@ public override int Execute() InstallComponent.SDK, new InstallRequestOptions()); - // Install the additional version directly using InstallerOrchestratorSingleton + // Install the additional version with the same progress settings as the main installation DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); if (additionalInstall == null) { diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index 9c28335cc124..6af4aecab880 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; internal static class SdkInstallCommandParser { - + public static readonly Argument ChannelArgument = new("channel") { @@ -37,6 +37,7 @@ internal static class SdkInstallCommandParser }; public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; + public static readonly Option NoProgressOption = CommonOptions.NoProgressOption; private static readonly Command SdkInstallCommand = ConstructCommand(); @@ -66,6 +67,7 @@ private static Command ConstructCommand() command.Options.Add(UpdateGlobalJsonOption); command.Options.Add(InteractiveOption); + command.Options.Add(NoProgressOption); command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); diff --git a/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs index f419e0a28cf3..855a5a8aaa45 100644 --- a/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; internal static class SdkUpdateCommandParser { - + public static readonly Option UpdateAllOption = new("--all") { Description = "Update all installed SDKs", @@ -22,6 +22,7 @@ internal static class SdkUpdateCommandParser }; public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; + public static readonly Option NoProgressOption = CommonOptions.NoProgressOption; private static readonly Command SdkUpdateCommand = ConstructCommand(); @@ -47,6 +48,7 @@ private static Command ConstructCommand() command.Options.Add(UpdateGlobalJsonOption); command.Options.Add(InteractiveOption); + command.Options.Add(NoProgressOption); command.SetAction(parseResult => 0); diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs index c643593ed8d6..943642c8a47a 100644 --- a/src/Installer/dnup/CommonOptions.cs +++ b/src/Installer/dnup/CommonOptions.cs @@ -17,6 +17,11 @@ internal class CommonOptions DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() }; + public static Option NoProgressOption = new("--no-progress") + { + Description = "Disables progress display for operations", + Arity = ArgumentArity.ZeroOrOne + }; private static bool IsCIEnvironmentOrRedirected() => new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; From 9577b9df64a9147acae00ac7ae8ef114d8007e0f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 14:48:08 -0700 Subject: [PATCH 071/153] Delete extraneous test file --- test/dnup.Tests/DnupEndToEndCollection.cs | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 test/dnup.Tests/DnupEndToEndCollection.cs diff --git a/test/dnup.Tests/DnupEndToEndCollection.cs b/test/dnup.Tests/DnupEndToEndCollection.cs deleted file mode 100644 index 921250737806..000000000000 --- a/test/dnup.Tests/DnupEndToEndCollection.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Microsoft.DotNet.Tools.Dnup.Tests; - -[CollectionDefinition("DnupEndToEndCollection", DisableParallelization = true)] -public class DnupEndToEndCollectionDefinition -{ -} From 44a60da6dd03f334e986cded1b59ea7487b41756 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 14:49:32 -0700 Subject: [PATCH 072/153] Add end to end tests for dnup This is slow and a less mature approach to unit testing, although I think we should focus our efforts in integration and e2e testing for now to ensure the broad cases work. This code will need better clean up later and has some re-implemented logic. --- test/dnup.Tests/ConcurrencyTests.cs | 92 ++++++++++ test/dnup.Tests/DnupCollections.cs | 36 ++++ test/dnup.Tests/DnupE2Etest.cs | 164 ++++++++++++++++++ test/dnup.Tests/Properties/AssemblyInfo.cs | 8 + .../Utilities/ConsoleOutputCapture.cs | 38 ++++ .../dnup.Tests/Utilities/DnupTestUtilities.cs | 70 ++++++++ test/dnup.Tests/Utilities/PathUtilities.cs | 34 ++++ test/dnup.Tests/Utilities/TestEnvironment.cs | 62 +++++++ .../Utilities/UpdateChannelExtensions.cs | 47 +++++ 9 files changed, 551 insertions(+) create mode 100644 test/dnup.Tests/ConcurrencyTests.cs create mode 100644 test/dnup.Tests/DnupCollections.cs create mode 100644 test/dnup.Tests/DnupE2Etest.cs create mode 100644 test/dnup.Tests/Properties/AssemblyInfo.cs create mode 100644 test/dnup.Tests/Utilities/ConsoleOutputCapture.cs create mode 100644 test/dnup.Tests/Utilities/DnupTestUtilities.cs create mode 100644 test/dnup.Tests/Utilities/PathUtilities.cs create mode 100644 test/dnup.Tests/Utilities/TestEnvironment.cs create mode 100644 test/dnup.Tests/Utilities/UpdateChannelExtensions.cs diff --git a/test/dnup.Tests/ConcurrencyTests.cs b/test/dnup.Tests/ConcurrencyTests.cs new file mode 100644 index 000000000000..873ef40e9281 --- /dev/null +++ b/test/dnup.Tests/ConcurrencyTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +/// +/// Tests that verify concurrency behavior of dnup installations. +/// Tests that multiple installations can happen concurrently without conflicts. +/// +[Collection("DnupConcurrencyCollection")] +public class ConcurrencyEndToEndTests +{ + /// + /// Test that verifies that multiple dnup instances can run simultaneously + /// without conflicts by using different install paths + /// + [Fact] + public async Task TestMultipleDnupInstances() + { + // We'll install multiple versions concurrently + var installTasks = new List>(); + + // Install different versions concurrently + installTasks.Add(InstallSdkAsync("9.0.100")); + installTasks.Add(InstallSdkAsync("9.0.101")); + installTasks.Add(InstallSdkAsync("9.0.102")); + + // Wait for all installations to complete + var results = await Task.WhenAll(installTasks); + + // Verify all installations succeeded + foreach (var result in results) + { + result.Should().BeTrue("All installations should succeed"); + } + } + + /// + /// Installs an SDK asynchronously in its own isolated environment + /// + private async Task InstallSdkAsync(string version) + { + return await Task.Run(() => + { + try + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + var args = DnupTestUtilities.BuildArguments(version, testEnv.InstallPath); + + Console.WriteLine($"Installing SDK {version}"); + int exitCode = Parser.Parse(args).Invoke(); + + if (exitCode != 0) + { + Console.WriteLine($"Installation of {version} failed with exit code {exitCode}"); + return false; + } + + // Verify the installation was recorded in the manifest + using var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + var manifest = new DnupSharedManifest(); + var installs = manifest.GetInstalledVersions(); + + var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath) && + i.Version.ToString() == version).ToList(); + + if (matchingInstalls.Count != 1) + { + Console.WriteLine($"Expected 1 installation of {version}, but found {matchingInstalls.Count}"); + return false; + } + + Console.WriteLine($"Installation of {version} completed successfully"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error installing {version}: {ex.Message}"); + return false; + } + }); + } +} diff --git a/test/dnup.Tests/DnupCollections.cs b/test/dnup.Tests/DnupCollections.cs new file mode 100644 index 000000000000..f81f54444d47 --- /dev/null +++ b/test/dnup.Tests/DnupCollections.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +/// +/// Collection definition that allows tests to run in parallel. +/// +[CollectionDefinition("DnupInstallCollection", DisableParallelization = false)] +public class DnupInstallCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} + +/// +/// Collection definition for reuse tests that allows tests to run in parallel. +/// +[CollectionDefinition("DnupReuseCollection", DisableParallelization = false)] +public class DnupReuseCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} + +/// +/// Collection definition for concurrency tests that allows tests to run in parallel. +/// +[CollectionDefinition("DnupConcurrencyCollection", DisableParallelization = false)] +public class DnupConcurrencyCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs new file mode 100644 index 000000000000..126d88f41ccb --- /dev/null +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -0,0 +1,164 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; +using Microsoft.Dotnet.Installation; +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +/// +/// Tests for installing different .NET SDK versions using dnup. +/// Each test run can happen in parallel with other tests in different collections. +/// +[Collection("DnupInstallCollection")] +public class InstallEndToEndTests +{ + public static IEnumerable InstallChannels => new List + { + new object[] { "9" }, + new object[] { "9.0" }, + new object[] { "9.0.103" }, + new object[] { "9.0.1xx" }, + new object[] { "latest" }, + new object[] { "preview" }, + new object[] { "sts" }, + new object[] { "lts" }, + }; + + /// + /// End-to-end test for installing different .NET SDK versions using dnup. + /// This test creates a temporary directory and sets the current directory to it + /// to avoid conflicts with the global.json in the repository root. + /// + [Theory] + [MemberData(nameof(InstallChannels))] + public void Test(string channel) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + // First verify what version dnup should resolve this channel to + var updateChannel = new UpdateChannel(channel); + var expectedVersion = new ManifestChannelVersionResolver().Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + updateChannel, + InstallComponent.SDK, + new InstallRequestOptions())); + + expectedVersion.Should().NotBeNull($"Channel {channel} should resolve to a valid version"); + + Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); + + // Execute the command + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath); + int exitCode = Parser.Parse(args).Invoke(); + exitCode.Should().Be(0); + + Directory.Exists(testEnv.InstallPath).Should().BeTrue(); + Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); + + // Verify the installation was properly recorded in the manifest + using var finalizeLock = new Microsoft.DotNet.Tools.Bootstrapper.ScopedMutex(Microsoft.DotNet.Tools.Bootstrapper.Constants.MutexNames.ModifyInstallationStates); + finalizeLock.HasHandle.Should().BeTrue(); + + var manifest = new Microsoft.DotNet.Tools.Bootstrapper.DnupSharedManifest(); + var installs = manifest.GetInstalledVersions(); + + installs.Should().NotBeEmpty(); + + var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); + matchingInstalls.Should().ContainSingle(); + + var install = matchingInstalls[0]; + install.Component.Should().Be(Microsoft.Dotnet.Installation.InstallComponent.SDK); + + // Verify the installed version matches what the resolver predicted + if (!updateChannel.IsFullySpecifiedVersion()) + { + // For channels that are not fully specified versions (like "9", "preview", "lts"), + // verify that the installed version matches what the resolver predicted + install.Version.ToString().Should().Be(expectedVersion!.ToString(), + $"Installed version should match resolved version for channel {channel}"); + } + else + { + // For fully specified versions (like "9.0.103"), the installed version should be exactly what was requested + install.Version.ToString().Should().Be(channel); + } + } +} + +/// +/// Tests that verify reuse behavior of dnup installations. +/// Each test run can happen in parallel with other tests in different collections. +/// +[Collection("DnupReuseCollection")] +public class ReuseEndToEndTests +{ + /// + /// Test that verifies that installing the same SDK version twice doesn't require + /// dnup to download and install it again. + /// + [Fact] + public void TestReusesExistingInstall() + { + // We'll use a specific version for this test to ensure consistent results + const string channel = "9.0.103"; + + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath); + + // Execute dnup to install the SDK the first time + Console.WriteLine($"First installation of {channel}"); + int exitCode = Parser.Parse(args).Invoke(); + exitCode.Should().Be(0); + + // Verify the installation was successful + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(); + var installs = manifest.GetInstalledVersions(); + installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + } + + // Now install the same SDK again and capture the console output + using var consoleOutput = new ConsoleOutputCapture(); + + Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); + exitCode = Parser.Parse(args).Invoke(); + exitCode.Should().Be(0); + + // Get the captured output + string output = consoleOutput.GetOutput(); + + // Verify the output contains a message indicating the SDK is already installed + output.Should().Contain("is already installed, skipping installation", + "dnup should detect that the SDK is already installed and skip the installation"); + + // The output should not contain download progress + output.Should().NotContain("Downloading .NET SDK", + "dnup should not attempt to download the SDK again"); + + // Verify the installation record in the manifest hasn't changed + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(); + var installs = manifest.GetInstalledVersions(); + var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); + + // Should still only have one installation + matchingInstalls.Should().ContainSingle(); + + // And it should be for the specified version + matchingInstalls[0].Version.ToString().Should().Be(channel); + } + } +} diff --git a/test/dnup.Tests/Properties/AssemblyInfo.cs b/test/dnup.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..7173633619d7 --- /dev/null +++ b/test/dnup.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using Xunit; + +// Enable parallel test execution +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass, DisableTestParallelization = false, MaxParallelThreads = 0)] diff --git a/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs b/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs new file mode 100644 index 000000000000..854c7039495d --- /dev/null +++ b/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Helper class to capture console output for testing +/// +internal class ConsoleOutputCapture : IDisposable +{ + private readonly TextWriter _originalConsoleOut; + private readonly StringBuilder _stringBuilder; + private readonly StringWriter _stringWriter; + + public ConsoleOutputCapture() + { + _originalConsoleOut = Console.Out; + _stringBuilder = new StringBuilder(); + _stringWriter = new StringWriter(_stringBuilder); + Console.SetOut(_stringWriter); + } + + public string GetOutput() + { + _stringWriter.Flush(); + return _stringBuilder.ToString(); + } + + public void Dispose() + { + Console.SetOut(_originalConsoleOut); + _stringWriter.Dispose(); + } +} diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs new file mode 100644 index 000000000000..564399aa27b4 --- /dev/null +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Common utilities for dnup tests +/// +internal static class DnupTestUtilities +{ + /// + /// Creates a test environment with proper temporary directories + /// + public static TestEnvironment CreateTestEnvironment() + { + string tempRoot = Path.Combine(Path.GetTempPath(), "dnup-e2e", Guid.NewGuid().ToString("N")); + string installPath = Path.Combine(tempRoot, "dotnet-root"); + string manifestPath = Path.Combine(tempRoot, "manifest", "dnup_manifest.json"); + + // Create necessary directories + Directory.CreateDirectory(tempRoot); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + Directory.CreateDirectory(installPath); + + return new TestEnvironment(tempRoot, installPath, manifestPath); + } + + /// + /// Builds command line arguments for dnup + /// + public static string[] BuildArguments(string channel, string installPath, bool disableProgress = true) + { + var args = new List + { + "sdk", + "install", + channel + }; + + args.Add("--install-path"); + args.Add(installPath); + args.Add("--interactive"); + args.Add("false"); + + // Add no-progress option when running tests in parallel to avoid Spectre.Console exclusivity issues + if (disableProgress) + { + args.Add("--no-progress"); + } + + return args.ToArray(); + } + + /// + /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture + /// + public static InstallArchitecture MapArchitecture(Architecture architecture) => architecture switch + { + Architecture.X86 => InstallArchitecture.x86, + Architecture.X64 => InstallArchitecture.x64, + Architecture.Arm64 => InstallArchitecture.arm64, + _ => throw new NotSupportedException($"Architecture {architecture} is not supported."), + }; +} diff --git a/test/dnup.Tests/Utilities/PathUtilities.cs b/test/dnup.Tests/Utilities/PathUtilities.cs new file mode 100644 index 000000000000..7e793865b869 --- /dev/null +++ b/test/dnup.Tests/Utilities/PathUtilities.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Utilities for working with file paths in tests +/// +internal static class PathUtilities +{ + /// + /// Compares two paths for equality, normalizing them first + /// + public static bool PathsEqual(string? first, string? second) + { + if (first == null && second == null) + { + return true; + } + + if (first == null || second == null) + { + return false; + } + + string normalizedFirst = Path.GetFullPath(first).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normalizedSecond = Path.GetFullPath(second).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return string.Equals(normalizedFirst, normalizedSecond, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/dnup.Tests/Utilities/TestEnvironment.cs b/test/dnup.Tests/Utilities/TestEnvironment.cs new file mode 100644 index 000000000000..1153598b479e --- /dev/null +++ b/test/dnup.Tests/Utilities/TestEnvironment.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Represents a temporary test environment with isolated directories and environment variables +/// +internal class TestEnvironment : IDisposable +{ + private readonly string? _originalManifestPath; + private readonly string? _originalDefaultInstallPath; + private readonly string _originalCurrentDirectory; + + public string TempRoot { get; } + public string InstallPath { get; } + public string ManifestPath { get; } + + public TestEnvironment(string tempRoot, string installPath, string manifestPath) + { + TempRoot = tempRoot; + InstallPath = installPath; + ManifestPath = manifestPath; + + // Store original environment values to restore later + _originalManifestPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); + _originalDefaultInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH"); + _originalCurrentDirectory = Environment.CurrentDirectory; + + // Set test environment variables + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", manifestPath); + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", installPath); + + // Change current directory to the temp directory to avoid global.json in repository root + Environment.CurrentDirectory = tempRoot; + } + + public void Dispose() + { + // Restore original environment + Environment.CurrentDirectory = _originalCurrentDirectory; + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", _originalManifestPath); + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", _originalDefaultInstallPath); + + // Clean up + if (Directory.Exists(TempRoot)) + { + try + { + Directory.Delete(TempRoot, recursive: true); + } + catch (IOException) + { + // Files might be locked, but we tried our best to clean up + Console.WriteLine($"Warning: Could not clean up temp directory: {TempRoot}"); + } + } + } +} diff --git a/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs new file mode 100644 index 000000000000..d62aa226d964 --- /dev/null +++ b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Extension methods for working with UpdateChannel in tests +/// +internal static class UpdateChannelExtensions +{ + /// + /// Determines if a channel represents a fully specified version (e.g. 9.0.103) + /// as opposed to a feature band (e.g. 9.0.1xx) or a special channel (e.g. lts) + /// + public static bool IsFullySpecifiedVersion(this UpdateChannel channel) + { + var parts = channel.Name.Split('.'); + + // Special channels are not fully specified versions + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // For a version to be fully specified, it needs at least 3 parts (major.minor.patch) + if (parts.Length < 3) + { + return false; + } + + // If the third part contains 'xx' (like '1xx'), it's a feature band, not a fully specified version + if (parts[2].Contains("xx")) + { + return false; + } + + // If we can parse the third part as an integer, it's likely a fully specified version + return int.TryParse(parts[2], out _); + } +} From 1fc9790f39d672966b9a2ab4bcdcdd6a95982f92 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 14:59:03 -0700 Subject: [PATCH 073/153] Remove extra concurrency tests as the e2e ones are already parallel --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 79 +++++++++++----- .../Commands/Sdk/Install/SdkInstallCommand.cs | 17 +--- .../dnup/InstallerOrchestratorSingleton.cs | 4 +- test/dnup.Tests/ConcurrencyTests.cs | 92 ------------------- 4 files changed, 62 insertions(+), 130 deletions(-) delete mode 100644 test/dnup.Tests/ConcurrencyTests.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 1510a597f220..fa5d95de57e3 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -16,13 +16,15 @@ internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable { private readonly DotnetInstallRequest _request; private readonly ReleaseVersion _resolvedVersion; + private readonly bool _noProgress; private string scratchDownloadDirectory; private string? _archivePath; - public ArchiveDotnetInstaller(DotnetInstallRequest request, ReleaseVersion resolvedVersion) + public ArchiveDotnetInstaller(DotnetInstallRequest request, ReleaseVersion resolvedVersion, bool noProgress = false) { _request = request; _resolvedVersion = resolvedVersion; + _noProgress = noProgress; scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; } @@ -32,19 +34,34 @@ public void Prepare() var archiveName = $"dotnet-{Guid.NewGuid()}"; _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); - Spectre.Console.AnsiConsole.Progress() - .Start(ctx => + if (_noProgress) + { + // When no-progress is enabled, download without progress display + Console.WriteLine($"Downloading .NET SDK {_resolvedVersion}..."); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, null); + if (!downloadSuccess) { - var downloadTask = ctx.AddTask($"Downloading .NET SDK {_resolvedVersion}", autoStart: true); - var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); - var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); - if (!downloadSuccess) + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); + } + Console.WriteLine($"Download of .NET SDK {_resolvedVersion} complete."); + } + else + { + // Use progress display for normal operation + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => { - throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); - } + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_resolvedVersion}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); + } - downloadTask.Value = 100; - }); + downloadTask.Value = 100; + }); + } } /** @@ -94,20 +111,38 @@ public void Commit(IEnumerable existingSdkVersions) throw new InvalidOperationException("Archive not found. Make sure Prepare() was called successfully."); } - Spectre.Console.AnsiConsole.Progress() - .Start(ctx => + if (_noProgress) + { + // When no-progress is enabled, install without progress display + Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); + if (extractResult != null) { - var installTask = ctx.AddTask($"Installing .NET SDK {_resolvedVersion}", autoStart: true); - - // Extract archive directly to target directory with special handling for muxer - var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); - if (extractResult != null) + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); + } + else + { + // Use progress display for normal operation + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => { - throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); - } + var installTask = ctx.AddTask($"Installing .NET SDK {_resolvedVersion}", autoStart: true); - installTask.Value = installTask.MaxValue; - }); + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); + if (extractResult != null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + installTask.Value = installTask.MaxValue; + }); + } } /** diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 227927524687..799f5d780979 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -210,20 +210,9 @@ public override int Execute() DotnetInstall? mainInstall; - // In no-progress mode, install directly without using a progress display - if (_noProgress) - { - // Install without progress display - mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); - } - else - { - // Create and use a progress context - var progressContext = SpectreAnsiConsole.Progress().Start(ctx => ctx); - - // Install the main SDK using the InstallerOrchestratorSingleton directly - mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest); - } + // Pass the _noProgress flag to the InstallerOrchestratorSingleton + // The orchestrator will handle installation with or without progress based on the flag + mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest, _noProgress); if (mainInstall == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index f17a6d64bc97..091e2e12eb42 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -22,7 +22,7 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); // Returns null on failure, DotnetInstall on success - public DotnetInstall? Install(DotnetInstallRequest installRequest) + public DotnetInstall? Install(DotnetInstallRequest installRequest, bool noProgress = false) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version ReleaseVersion? versionToInstall = new ManifestChannelVersionResolver().Resolve(installRequest); @@ -49,7 +49,7 @@ private InstallerOrchestratorSingleton() } } - using ArchiveDotnetInstaller installer = new(installRequest, versionToInstall); + using ArchiveDotnetInstaller installer = new(installRequest, versionToInstall, noProgress); installer.Prepare(); // Extract and commit the install to the directory diff --git a/test/dnup.Tests/ConcurrencyTests.cs b/test/dnup.Tests/ConcurrencyTests.cs deleted file mode 100644 index 873ef40e9281..000000000000 --- a/test/dnup.Tests/ConcurrencyTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.DotNet.Tools.Bootstrapper; -using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; -using Xunit; - -namespace Microsoft.DotNet.Tools.Dnup.Tests; - -/// -/// Tests that verify concurrency behavior of dnup installations. -/// Tests that multiple installations can happen concurrently without conflicts. -/// -[Collection("DnupConcurrencyCollection")] -public class ConcurrencyEndToEndTests -{ - /// - /// Test that verifies that multiple dnup instances can run simultaneously - /// without conflicts by using different install paths - /// - [Fact] - public async Task TestMultipleDnupInstances() - { - // We'll install multiple versions concurrently - var installTasks = new List>(); - - // Install different versions concurrently - installTasks.Add(InstallSdkAsync("9.0.100")); - installTasks.Add(InstallSdkAsync("9.0.101")); - installTasks.Add(InstallSdkAsync("9.0.102")); - - // Wait for all installations to complete - var results = await Task.WhenAll(installTasks); - - // Verify all installations succeeded - foreach (var result in results) - { - result.Should().BeTrue("All installations should succeed"); - } - } - - /// - /// Installs an SDK asynchronously in its own isolated environment - /// - private async Task InstallSdkAsync(string version) - { - return await Task.Run(() => - { - try - { - using var testEnv = DnupTestUtilities.CreateTestEnvironment(); - var args = DnupTestUtilities.BuildArguments(version, testEnv.InstallPath); - - Console.WriteLine($"Installing SDK {version}"); - int exitCode = Parser.Parse(args).Invoke(); - - if (exitCode != 0) - { - Console.WriteLine($"Installation of {version} failed with exit code {exitCode}"); - return false; - } - - // Verify the installation was recorded in the manifest - using var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); - var manifest = new DnupSharedManifest(); - var installs = manifest.GetInstalledVersions(); - - var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath) && - i.Version.ToString() == version).ToList(); - - if (matchingInstalls.Count != 1) - { - Console.WriteLine($"Expected 1 installation of {version}, but found {matchingInstalls.Count}"); - return false; - } - - Console.WriteLine($"Installation of {version} completed successfully"); - return true; - } - catch (Exception ex) - { - Console.WriteLine($"Error installing {version}: {ex.Message}"); - return false; - } - }); - } -} From be9bae37d9f53e44885babd2cd2508bae4c7bbe6 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 08:13:24 -0400 Subject: [PATCH 074/153] Remove DotnetVersion --- src/Installer/dnup/DnupManifestJsonContext.cs | 4 +- src/Installer/dnup/DotnetVersion.cs | 430 ------------------ .../dnup/DotnetVersionJsonConverter.cs | 73 --- test/dnup.Tests/DotnetVersionTests.cs | 212 --------- 4 files changed, 1 insertion(+), 718 deletions(-) delete mode 100644 src/Installer/dnup/DotnetVersion.cs delete mode 100644 src/Installer/dnup/DotnetVersionJsonConverter.cs delete mode 100644 test/dnup.Tests/DotnetVersionTests.cs diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 13e5a6ff3bff..4db94209daee 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -7,10 +7,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper { [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - Converters = new[] { typeof(DotnetVersionJsonConverter), typeof(ReleaseVersionJsonConverter) })] + Converters = new[] { typeof(ReleaseVersionJsonConverter) })] [JsonSerializable(typeof(List))] - [JsonSerializable(typeof(DotnetVersion))] - [JsonSerializable(typeof(DotnetVersionType))] [JsonSerializable(typeof(InstallComponent))] [JsonSerializable(typeof(InstallArchitecture))] [JsonSerializable(typeof(InstallType))] diff --git a/src/Installer/dnup/DotnetVersion.cs b/src/Installer/dnup/DotnetVersion.cs deleted file mode 100644 index cdc6cb864021..000000000000 --- a/src/Installer/dnup/DotnetVersion.cs +++ /dev/null @@ -1,430 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Text.Json.Serialization; -using Microsoft.Deployment.DotNet.Releases; - -namespace Microsoft.DotNet.Tools.Bootstrapper; - -/// -/// Represents the type of .NET version (SDK or Runtime). -/// -public enum DotnetVersionType -{ - /// Automatically detect based on version format. - Auto, - /// SDK version (has feature bands, e.g., 8.0.301). - Sdk, - /// Runtime version (no feature bands, e.g., 8.0.7). - Runtime -} - -/// -/// Represents a .NET version string with specialized parsing, comparison, and manipulation capabilities. -/// Acts like a string but provides version-specific operations like feature band extraction and semantic comparisons. -/// Supports both SDK versions (with feature bands) and Runtime versions, and handles build hashes and preview versions. -/// -[DebuggerDisplay("{Value} ({VersionType})")] -[JsonConverter(typeof(DotnetVersionJsonConverter))] -public readonly record struct DotnetVersion : IComparable, IComparable, IEquatable -{ - private readonly ReleaseVersion? _releaseVersion; - - /// Gets the original version string value. - public string Value { get; } - - /// Gets the version type (SDK or Runtime). - public DotnetVersionType VersionType { get; } - - /// Gets the major version component (e.g., "8" from "8.0.301"). - public int Major => _releaseVersion?.Major ?? ParseMajorDirect(); - - /// Gets the minor version component (e.g., "0" from "8.0.301"). - public int Minor => _releaseVersion?.Minor ?? ParseMinorDirect(); - - /// Gets the patch version component (e.g., "301" from "8.0.301"). - public int Patch => _releaseVersion?.Patch ?? 0; - - /// Gets the major.minor version string (e.g., "8.0" from "8.0.301"). - public string MajorMinor => $"{Major}.{Minor}"; - - /// Gets whether this version represents a preview version (contains preview, rc, alpha, beta, etc.). - public bool IsPreview => Value.Contains("-preview", StringComparison.OrdinalIgnoreCase) || - Value.Contains("-rc", StringComparison.OrdinalIgnoreCase) || - Value.Contains("-alpha", StringComparison.OrdinalIgnoreCase) || - Value.Contains("-beta", StringComparison.OrdinalIgnoreCase); - - /// Gets whether this version represents a prerelease (contains '-' but not just build hash). - public bool IsPrerelease => Value.Contains('-') && !IsOnlyBuildHash(); - - /// Gets whether this is an SDK version (has feature bands). - public bool IsSdkVersion => VersionType == DotnetVersionType.Sdk || - (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Sdk); - - /// Gets whether this is a Runtime version (no feature bands). - public bool IsRuntimeVersion => VersionType == DotnetVersionType.Runtime || - (VersionType == DotnetVersionType.Auto && DetectVersionType() == DotnetVersionType.Runtime); - - /// Gets whether this version contains a build hash. - public bool HasBuildHash => GetBuildHash() is not null; - - /// Gets whether this version is fully specified (e.g., "8.0.301" vs "8.0" or "8.0.3xx"). - public bool IsFullySpecified => _releaseVersion is not null && - !Value.Contains('x') && - Value.Split('.').Length >= 3; - - /// Gets whether this version uses a non-specific feature band pattern (e.g., "8.0.3xx"). - public bool IsNonSpecificFeatureBand => Value.EndsWith('x') && Value.Split('.').Length == 3; - - /// Gets whether this is just a major or major.minor version (e.g., "8" or "8.0"). - public bool IsNonSpecificMajorMinor => Value.Split('.').Length <= 2 && - Value.Split('.').All(x => int.TryParse(x, out _)); - - /// - /// Initializes a new instance with the specified version string. - /// - /// The version string to parse. - /// The type of version (SDK or Runtime). Auto-detects if not specified. - public DotnetVersion(string? value, DotnetVersionType versionType = DotnetVersionType.Auto) - { - Value = value ?? string.Empty; - VersionType = versionType; - _releaseVersion = !string.IsNullOrEmpty(Value) && ReleaseVersion.TryParse(GetVersionWithoutBuildHash(), out var version) ? version : null; - } - - /// - /// Gets the feature band number from the SDK version (e.g., "3" from "8.0.301"). - /// Returns null if this is not an SDK version or doesn't contain a feature band. - /// - public string? GetFeatureBand() - { - if (!IsSdkVersion) return null; - - var parts = GetVersionWithoutBuildHash().Split('.'); - if (parts.Length < 3) return null; - - var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix - - // For SDK versions, feature band is the hundreds digit - // Runtime versions like "8.0.7" should return null, not "7" - if (patchPart.Length < 3) return null; - - return patchPart.Length > 0 ? patchPart[0].ToString() : null; - } - - /// - /// Gets the feature band patch version (e.g., "01" from "8.0.301"). - /// Returns null if this is not an SDK version or doesn't contain a feature band. - /// - public string? GetFeatureBandPatch() - { - if (!IsSdkVersion) return null; - - var parts = GetVersionWithoutBuildHash().Split('.'); - if (parts.Length < 3) return null; - - var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix - - // For SDK versions, patch is the last two digits - if (patchPart.Length < 3) return null; - - return patchPart.Length > 1 ? patchPart[1..] : null; - } - - /// - /// Gets the complete feature band including patch (e.g., "301" from "8.0.301"). - /// Returns null if this is not an SDK version or doesn't contain a feature band. - /// - public string? GetCompleteBandAndPatch() - { - if (!IsSdkVersion) return null; - - var parts = GetVersionWithoutBuildHash().Split('.'); - if (parts.Length < 3) return null; - - var patchPart = parts[2].Split('-')[0]; // Remove prerelease suffix - - // For SDK versions, complete band is 3-digit patch - if (patchPart.Length < 3) return null; - - return patchPart; - } - - /// - /// Gets the prerelease identifier if this is a prerelease version. - /// - public string? GetPrereleaseIdentifier() - { - var dashIndex = Value.IndexOf('-'); - return dashIndex >= 0 ? Value[(dashIndex + 1)..] : null; - } - - /// - /// Gets the build hash from the version if present (typically after a '+' or at the end of prerelease). - /// Examples: "8.0.301+abc123" -> "abc123", "8.0.301-preview.1.abc123" -> "abc123" - /// - public string? GetBuildHash() - { - // Build hash after '+' - var plusIndex = Value.IndexOf('+'); - if (plusIndex >= 0) - return Value[(plusIndex + 1)..]; - - // Build hash in prerelease (look for hex-like string at the end) - var prerelease = GetPrereleaseIdentifier(); - if (prerelease is null) return null; - - var parts = prerelease.Split('.'); - var lastPart = parts[^1]; - - // Check if last part looks like a build hash (hex string, 6+ chars) - if (lastPart.Length >= 6 && lastPart.All(c => char.IsAsciiHexDigit(c))) - return lastPart; - - return null; - } - - /// - /// Gets the version string without any build hash component. - /// - public string GetVersionWithoutBuildHash() - { - var buildHash = GetBuildHash(); - if (buildHash is null) return Value; - - // Remove build hash after '+' - var plusIndex = Value.IndexOf('+'); - if (plusIndex >= 0) - return Value[..plusIndex]; - - // Remove build hash from prerelease - return Value.Replace($".{buildHash}", ""); - } - - public bool IsValidMajorVersion() - { - return Major != 0; - } - - /// - /// Detects whether this is an SDK or Runtime version based on the version format. - /// SDK versions typically have 3-digit patch numbers (feature bands), Runtime versions have 1-2 digit patch numbers. - /// - private DotnetVersionType DetectVersionType() - { - var parts = GetVersionWithoutBuildHash().Split('.', '-'); - if (parts.Length < 3) return DotnetVersionType.Runtime; - - var patchPart = parts[2]; - - // SDK versions typically have 3-digit patch numbers (e.g., 301, 201) - // Runtime versions have 1-2 digit patch numbers (e.g., 7, 12) - if (patchPart.Length >= 3 && patchPart.All(char.IsDigit)) - return DotnetVersionType.Sdk; - - return DotnetVersionType.Runtime; - } - - /// - /// Checks if the version only contains a build hash (no other prerelease identifiers). - /// - private bool IsOnlyBuildHash() - { - var dashIndex = Value.IndexOf('-'); - if (dashIndex < 0) return false; - - var afterDash = Value[(dashIndex + 1)..]; - - // Check if what follows the dash is just a build hash - return afterDash.Length >= 6 && afterDash.All(c => char.IsAsciiHexDigit(c)); - } - - /// - /// Creates a new version with the specified patch version while preserving other components. - /// - public DotnetVersion WithPatch(int patch) - { - var parts = Value.Split('.'); - if (parts.Length < 3) - return new DotnetVersion($"{Major}.{Minor}.{patch:D3}"); - - var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); - return new DotnetVersion($"{Major}.{Minor}.{patch:D3}{prereleaseAndBuild}"); - } - - /// - /// Creates a new version with the specified feature band while preserving other components. - /// - public DotnetVersion WithFeatureBand(int featureBand) - { - var currentPatch = GetFeatureBandPatch(); - var patch = $"{featureBand}{currentPatch ?? "00"}"; - var prereleaseAndBuild = GetPrereleaseAndBuildSuffix(); - return new DotnetVersion($"{Major}.{Minor}.{patch}{prereleaseAndBuild}"); - } - - private string GetPrereleaseAndBuildSuffix() - { - var dashIndex = Value.IndexOf('-'); - return dashIndex >= 0 ? Value[dashIndex..] : string.Empty; - } - - /// - /// Validates that this version string represents a well-formed, fully specified version. - /// - public bool IsValidFullySpecifiedVersion() - { - if (!IsFullySpecified) return false; - - var parts = Value.Split('.', '-')[0].Split('.'); - if (parts.Length < 3 || Value.Length > 20) return false; - - // Check that patch version is reasonable (1-2 digits for feature band, 1-2 for patch) - return parts.All(p => int.TryParse(p, out _)) && parts[2].Length is >= 2 and <= 3; - } - - /// - /// Parses major version directly from string for cases where ReleaseVersion parsing fails. - /// - private int ParseMajorDirect() - { - var parts = Value.Split('.'); - return parts.Length > 0 && int.TryParse(parts[0], out var major) ? major : 0; - } - - /// - /// Parses minor version directly from string for cases where ReleaseVersion parsing fails. - /// - private int ParseMinorDirect() - { - var parts = Value.Split('.'); - return parts.Length > 1 && int.TryParse(parts[1], out var minor) ? minor : 0; - } - - #region String-like behavior - - public static implicit operator string(DotnetVersion version) => version.Value; - public static implicit operator DotnetVersion(string version) => new(version); - - /// - /// Creates an SDK version from a string. - /// - public static DotnetVersion FromSdk(string version) => new(version, DotnetVersionType.Sdk); - - /// - /// Creates a Runtime version from a string. - /// - public static DotnetVersion FromRuntime(string version) => new(version, DotnetVersionType.Runtime); - - public override string ToString() => Value; - - public bool Equals(string? other) => string.Equals(Value, other, StringComparison.Ordinal); - - #endregion - - #region IComparable implementations - - public int CompareTo(DotnetVersion other) - { - // Use semantic version comparison if both are valid release versions - if (_releaseVersion is not null && other._releaseVersion is not null) - return _releaseVersion.CompareTo(other._releaseVersion); - - // Fall back to string comparison - return string.Compare(Value, other.Value, StringComparison.Ordinal); - } - - public int CompareTo(string? other) - { - if (other is null) return 1; - return CompareTo(new DotnetVersion(other)); - } - - #endregion - - #region Static utility methods - - /// - /// Determines whether the specified string represents a valid .NET version format. - /// - public static bool IsValidFormat(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return false; - - var version = new DotnetVersion(value); - - // Valid formats: - // - Fully specified versions: "8.0.301", "7.0.201" - // - Non-specific feature bands: "7.0.2xx" - // - Major.minor versions: "8.0", "7.0" - // - Major only versions: "8", "7" - // - Exclude unreasonable versions like high patch numbers or runtime-like versions with small patch - - if (version.IsFullySpecified) - { - var parts = value.Split('.'); - if (parts.Length >= 3 && int.TryParse(parts[2], out var patch)) - { - // Unreasonably high patch numbers are invalid (e.g., 7.0.1999) - if (patch > 999) return false; - - // Small patch numbers (1-2 digits) are runtime versions and should be valid - // but versions like "7.1.10" are questionable since .NET 7.1 doesn't exist - if (patch < 100 && version.Major <= 8 && version.Minor > 0) return false; - } - return true; - } - - if (version.IsNonSpecificFeatureBand) return true; - - if (version.IsNonSpecificMajorMinor) - { - // Allow reasonable major.minor combinations - // Exclude things like "10.10" which don't make sense for .NET versioning - if (version.Major <= 20 && version.Minor <= 9) return true; - } - - return false; - } - - /// - /// Tries to parse a version string into a DotnetVersion. - /// - /// The version string to parse. - /// The parsed version if successful. - /// The type of version to parse. Auto-detects if not specified. - public static bool TryParse(string? value, out DotnetVersion version, DotnetVersionType versionType = DotnetVersionType.Auto) - { - version = new DotnetVersion(value, versionType); - return IsValidFormat(value); - } - - /// - /// Parses a version string into a DotnetVersion, throwing on invalid format. - /// - /// The version string to parse. - /// The type of version to parse. Auto-detects if not specified. - public static DotnetVersion Parse(string value, DotnetVersionType versionType = DotnetVersionType.Auto) - { - if (!TryParse(value, out var version, versionType)) - throw new ArgumentException($"'{value}' is not a valid .NET version format.", nameof(value)); - return version; - } - - #endregion - - #region String comparison operators - - public static bool operator <(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) < 0; - public static bool operator <=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) <= 0; - public static bool operator >(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) > 0; - public static bool operator >=(DotnetVersion left, DotnetVersion right) => left.CompareTo(right) >= 0; - - public static bool operator ==(DotnetVersion left, string? right) => left.Equals(right); - public static bool operator !=(DotnetVersion left, string? right) => !left.Equals(right); - public static bool operator ==(string? left, DotnetVersion right) => right.Equals(left); - public static bool operator !=(string? left, DotnetVersion right) => !right.Equals(left); - - #endregion -} diff --git a/src/Installer/dnup/DotnetVersionJsonConverter.cs b/src/Installer/dnup/DotnetVersionJsonConverter.cs deleted file mode 100644 index 4ff20013e0c1..000000000000 --- a/src/Installer/dnup/DotnetVersionJsonConverter.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - /// - /// A custom JSON converter for the DotnetVersion struct. - /// This ensures proper serialization and deserialization of the struct. - /// - public class DotnetVersionJsonConverter : JsonConverter - { - /// - /// Reads and converts the JSON to a DotnetVersion struct. - /// - public override DotnetVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - string? versionString = reader.GetString(); - return new DotnetVersion(versionString); - } - else if (reader.TokenType == JsonTokenType.StartObject) - { - string? versionString = null; - DotnetVersionType versionType = DotnetVersionType.Auto; - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType == JsonTokenType.PropertyName) - { - string? propertyName = reader.GetString(); - reader.Read(); // Move to the property value - - if (propertyName != null && propertyName.Equals("value", StringComparison.OrdinalIgnoreCase)) - { - versionString = reader.GetString(); - } - else if (propertyName != null && propertyName.Equals("versionType", StringComparison.OrdinalIgnoreCase)) - { - versionType = (DotnetVersionType)reader.GetInt32(); - } - } - } - - return new DotnetVersion(versionString, versionType); - } - else if (reader.TokenType == JsonTokenType.Null) - { - return new DotnetVersion(null); - } - - throw new JsonException($"Unexpected token {reader.TokenType} when deserializing DotnetVersion"); - } - - /// - /// Writes a DotnetVersion struct as JSON. - /// - public override void Write(Utf8JsonWriter writer, DotnetVersion value, JsonSerializerOptions options) - { - if (string.IsNullOrEmpty(value.Value)) - { - writer.WriteNullValue(); - return; - } - writer.WriteStringValue(value.Value); - - } - } -} diff --git a/test/dnup.Tests/DotnetVersionTests.cs b/test/dnup.Tests/DotnetVersionTests.cs deleted file mode 100644 index cf661a8e2366..000000000000 --- a/test/dnup.Tests/DotnetVersionTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Tools.Bootstrapper; - -namespace Microsoft.DotNet.Tools.Dnup.Tests; - -public class DotnetVersionTests -{ - [Theory] - [InlineData("7.0.201", "7")] - [InlineData("7.0.2xx", "7")] - [InlineData("7.1.300", "7")] - [InlineData("10.0.102", "10")] - [InlineData("7", "7")] - [InlineData("7.0", "7")] - public void GetMajor(string version, string expected) => - new DotnetVersion(version).Major.ToString().Should().Be(expected); - - [Theory] - [InlineData("7.0.201", "0")] - [InlineData("7.1.300", "1")] - [InlineData("10.0.102", "0")] - [InlineData("7", "0")] - [InlineData("7.0", "0")] - public void GetMinor(string version, string expected) => - new DotnetVersion(version).Minor.ToString().Should().Be(expected); - - [Theory] - [InlineData("7.0.201", "7.0")] - [InlineData("7.0.2xx", "7.0")] - [InlineData("7.1.300", "7.1")] - [InlineData("10.0.102", "10.0")] - [InlineData("7", "7.0")] - [InlineData("7.0", "7.0")] - public void GetMajorMinor(string version, string expected) => - new DotnetVersion(version).MajorMinor.Should().Be(expected); - - [Theory] - [InlineData("7.0.201", "2")] - [InlineData("7.0.2xx", "2")] - [InlineData("7.1.300", "3")] - [InlineData("10.0.102", "1")] - [InlineData("7.0.221", "2")] - [InlineData("7.0.7", null)] - [InlineData("8.0", null)] - public void GetFeatureBand(string version, string? expected) => - DotnetVersion.FromSdk(version).GetFeatureBand().Should().Be(expected); - - [Theory] - [InlineData("7.0.201", "01")] - [InlineData("7.1.300", "00")] - [InlineData("10.0.102", "02")] - [InlineData("7.0.221", "21")] - [InlineData("8.0.400-preview.0.24324.5", "00")] - [InlineData("7.0.7", null)] - [InlineData("8.0", null)] - public void GetFeatureBandPatch(string version, string? expected) => - DotnetVersion.FromSdk(version).GetFeatureBandPatch().Should().Be(expected); - - [Theory] - [InlineData("7.0.201", "201")] - [InlineData("7.1.300", "300")] - [InlineData("10.0.102", "102")] - [InlineData("7.0.221", "221")] - [InlineData("7.0.7", null)] - [InlineData("8.0", null)] - public void GetCompleteBandAndPatch(string version, string? expected) => - DotnetVersion.FromSdk(version).GetCompleteBandAndPatch().Should().Be(expected); - - [Theory] - [InlineData("7.0", null)] - [InlineData("8.0.10", "10")] - [InlineData("8.0.9-rc.2.24502.A", "9")] - public void GetRuntimePatch(string version, string? expected) - { - var v = DotnetVersion.FromRuntime(version); - var patch = v.Patch == 0 ? null : v.Patch.ToString(); - patch.Should().Be(expected); - } - - [Theory] - [InlineData("8.0.400-preview.0.24324.5", true)] - [InlineData("9.0.0-rc.2", true)] - [InlineData("9.0.0-rc.2.24473.5", true)] - [InlineData("8.0.0-preview.7", true)] - [InlineData("10.0.0-alpha.2.24522.8", true)] - [InlineData("7.0.2xx", false)] - [InlineData("7.0", false)] - [InlineData("7.1.10", false)] - [InlineData("7.0.201", false)] - [InlineData("10.0.100-rc.2.25420.109", true)] - public void IsPreview(string version, bool expected) => - new DotnetVersion(version).IsPreview.Should().Be(expected); - - [Theory] - [InlineData("7.0.201", false)] - [InlineData("7.0.2xx", true)] - [InlineData("10.0.102", false)] - public void IsNonSpecificFeatureBand(string version, bool expected) => - new DotnetVersion(version).IsNonSpecificFeatureBand.Should().Be(expected); - - [Theory] - [InlineData("7.0.201", true)] - [InlineData("7.1.300", true)] - [InlineData("10.0.102", true)] - [InlineData("7", false)] - [InlineData("7.0.2xx", false)] - [InlineData("7.0", false)] - public void IsFullySpecified(string version, bool expected) => - new DotnetVersion(version).IsFullySpecified.Should().Be(expected); - - [Theory] - [InlineData("7.0.201", false)] - [InlineData("7.1.300", false)] - [InlineData("10.0.102", false)] - [InlineData("7", true)] - [InlineData("7.0.2xx", false)] - [InlineData("7.0", true)] - public void IsNonSpecificMajorMinor(string version, bool expected) => - new DotnetVersion(version).IsNonSpecificMajorMinor.Should().Be(expected); - - [Theory] - [InlineData("7.0.201", true)] - [InlineData("7.1.300", true)] - [InlineData("10.0.102", true)] - [InlineData("7.0.2xx", true)] - [InlineData("7", true)] - [InlineData("7.0", true)] - [InlineData("7.0.1999", false)] - [InlineData("7.1.10", false)] - [InlineData("10.10", false)] - public void IsValidFormat(string version, bool expected) => - DotnetVersion.IsValidFormat(version).Should().Be(expected); - - [Theory] - [InlineData("8.0.301", 0, true, false)] // Auto - [InlineData("8.0.7", 0, false, true)] // Auto - [InlineData("8.0.301", 1, true, false)] // Sdk - [InlineData("8.0.7", 2, false, true)] // Runtime - [InlineData("8.0.7", 1, true, false)] // Sdk - public void VersionTypeDetection(string version, int typeInt, bool isSdk, bool isRuntime) - { - var type = (DotnetVersionType)typeInt; - var v = new DotnetVersion(version, type); - v.IsSdkVersion.Should().Be(isSdk); - v.IsRuntimeVersion.Should().Be(isRuntime); - } - - [Theory] - [InlineData("8.0.301+abc123def456", "abc123def456")] - [InlineData("8.0.301-preview.1.abc123", "abc123")] - [InlineData("8.0.301-abc123def", "abc123def")] - [InlineData("8.0.301", null)] - [InlineData("8.0.301-preview.1", null)] - public void GetBuildHash(string version, string? expected) => - new DotnetVersion(version).GetBuildHash().Should().Be(expected); - - [Theory] - [InlineData("8.0.301+abc123def456", "8.0.301")] - [InlineData("8.0.301-preview.1.abc123", "8.0.301-preview.1")] - [InlineData("8.0.301", "8.0.301")] - public void GetVersionWithoutBuildHash(string version, string expected) => - new DotnetVersion(version).GetVersionWithoutBuildHash().Should().Be(expected); - - [Theory] - [InlineData("8.0.301", "8.0.302", -1)] - [InlineData("8.0.302", "8.0.301", 1)] - [InlineData("8.0.301", "8.0.301", 0)] - public void Comparison(string v1, string v2, int expected) - { - var result = new DotnetVersion(v1).CompareTo(new DotnetVersion(v2)); - if (expected < 0) result.Should().BeNegative(); - else if (expected > 0) result.Should().BePositive(); - else result.Should().Be(0); - } - - [Fact] - public void FactoryMethods() - { - var sdk = DotnetVersion.FromSdk("8.0.7"); - var runtime = DotnetVersion.FromRuntime("8.0.301"); - - sdk.IsSdkVersion.Should().BeTrue(); - sdk.IsRuntimeVersion.Should().BeFalse(); - runtime.IsSdkVersion.Should().BeFalse(); - runtime.IsRuntimeVersion.Should().BeTrue(); - } - - [Fact] - public void ImplicitConversions() - { - DotnetVersion version = "8.0.301"; - string versionString = version; - - version.Value.Should().Be("8.0.301"); - versionString.Should().Be("8.0.301"); - } - - [Fact] - public void TryParse() - { - DotnetVersion.TryParse("8.0.301", out var valid).Should().BeTrue(); - valid.Value.Should().Be("8.0.301"); - - DotnetVersion.TryParse("invalid", out _).Should().BeFalse(); - } - - [Fact] - public void Parse() => - new Action(() => DotnetVersion.Parse("invalid")).Should().Throw(); -} From 6c9b582e0aaff77273d18bff65e3fc3535814148 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 08:22:43 -0400 Subject: [PATCH 075/153] Rename IBootstrapperController to IDotnetInstallManager --- .../Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs | 2 +- src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs | 2 +- .../{BootstrapperController.cs => DotnetInstallManager.cs} | 4 ++-- .../{IBootstrapperController.cs => IDotnetInstallManager.cs} | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/Installer/dnup/{BootstrapperController.cs => DotnetInstallManager.cs} (97%) rename src/Installer/dnup/{IBootstrapperController.cs => IDotnetInstallManager.cs} (97%) diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs index 80b332521b4d..8eaa946819f1 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -11,7 +11,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install { - internal class EnvironmentVariableMockDotnetInstaller : IBootstrapperController + internal class EnvironmentVariableMockDotnetInstaller : IDotnetInstallManager { public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) { diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 799f5d780979..4dd2a6355aa8 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -22,7 +22,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); private readonly bool _noProgress = result.GetValue(SdkInstallCommandParser.NoProgressOption); - private readonly IBootstrapperController _dotnetInstaller = new BootstrapperController(); + private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager(); private readonly IDotnetReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/DotnetInstallManager.cs similarity index 97% rename from src/Installer/dnup/BootstrapperController.cs rename to src/Installer/dnup/DotnetInstallManager.cs index ffc4ddc7f030..d63073f1184b 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -10,11 +10,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public class BootstrapperController : IBootstrapperController +public class DotnetInstallManager : IDotnetInstallManager { private readonly IEnvironmentProvider _environmentProvider; - public BootstrapperController(IEnvironmentProvider? environmentProvider = null) + public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) { _environmentProvider = environmentProvider ?? new EnvironmentProvider(); } diff --git a/src/Installer/dnup/IBootstrapperController.cs b/src/Installer/dnup/IDotnetInstallManager.cs similarity index 97% rename from src/Installer/dnup/IBootstrapperController.cs rename to src/Installer/dnup/IDotnetInstallManager.cs index 5b9752c664f5..1244ea90a3e1 100644 --- a/src/Installer/dnup/IBootstrapperController.cs +++ b/src/Installer/dnup/IDotnetInstallManager.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -public interface IBootstrapperController +public interface IDotnetInstallManager { GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); From eb6e2af15c276e4042c808dcc1f8bc6e53ba012b Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 12:23:56 -0400 Subject: [PATCH 076/153] Delete one of the IDotnetInstaller interfaces --- src/Installer/dnup/ArchiveDotnetInstaller.cs | 2 +- src/Installer/dnup/IDotnetInstaller.cs | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 src/Installer/dnup/IDotnetInstaller.cs diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index fa5d95de57e3..aadc9cc0f181 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -12,7 +12,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; -internal class ArchiveDotnetInstaller : IDotnetInstaller, IDisposable +internal class ArchiveDotnetInstaller : IDisposable { private readonly DotnetInstallRequest _request; private readonly ReleaseVersion _resolvedVersion; diff --git a/src/Installer/dnup/IDotnetInstaller.cs b/src/Installer/dnup/IDotnetInstaller.cs deleted file mode 100644 index e811c4562e23..000000000000 --- a/src/Installer/dnup/IDotnetInstaller.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - internal interface IDotnetInstaller - { - void Prepare(); - void Commit(); - } -} From c2b76884ec70db01fe5e892addfea09da628b8b5 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 13:13:27 -0400 Subject: [PATCH 077/153] Move ArchiveDotnetInstaller and dependencies to library --- .../AssemblyInfo.cs | 10 +++++++ .../Internal}/ArchiveDotnetInstaller.cs | 2 +- .../Internal}/DnupUtilities.cs | 2 +- .../Internal}/DotnetInstall.cs | 8 +++--- .../Internal}/ReleaseManifest.cs | 2 +- .../Internal}/ScopedMutex.cs | 2 +- .../SpectreDownloadProgressReporter.cs | 2 +- .../Internal/UpdateChannel.cs | 25 ++++++++++++++++++ .../Microsoft.Dotnet.Installation.csproj | 2 ++ .../dnup/ArchiveInstallationValidator.cs | 1 + .../EnvironmentVariableMockDotnetInstaller.cs | 1 + .../Commands/Sdk/Install/SdkInstallCommand.cs | 1 + src/Installer/dnup/DnupManifestJsonContext.cs | 3 ++- src/Installer/dnup/DnupSharedManifest.cs | 1 + src/Installer/dnup/DotnetInstallManager.cs | 1 + src/Installer/dnup/IDnupManifest.cs | 1 + src/Installer/dnup/IInstallationValidator.cs | 1 + .../dnup/InstallerOrchestratorSingleton.cs | 1 + .../dnup/ManifestChannelVersionResolver.cs | 1 + src/Installer/dnup/UpdateChannel.cs | 26 ------------------- 20 files changed, 57 insertions(+), 36 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/ArchiveDotnetInstaller.cs (99%) rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/DnupUtilities.cs (98%) rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/DotnetInstall.cs (84%) rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/ReleaseManifest.cs (99%) rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/ScopedMutex.cs (92%) rename src/Installer/{dnup => Microsoft.Dotnet.Installation/Internal}/SpectreDownloadProgressReporter.cs (96%) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs delete mode 100644 src/Installer/dnup/UpdateChannel.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs new file mode 100644 index 000000000000..2e500c68657c --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +[assembly: InternalsVisibleTo("dnup, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] + diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs similarity index 99% rename from src/Installer/dnup/ArchiveDotnetInstaller.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs index aadc9cc0f181..370698392616 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs @@ -10,7 +10,7 @@ using System.Runtime.InteropServices; using Microsoft.Deployment.DotNet.Releases; -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.Dotnet.Installation.Internal; internal class ArchiveDotnetInstaller : IDisposable { diff --git a/src/Installer/dnup/DnupUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs similarity index 98% rename from src/Installer/dnup/DnupUtilities.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs index 5ef1738bb4a3..d8b3f5f7d10d 100644 --- a/src/Installer/dnup/DnupUtilities.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs @@ -7,7 +7,7 @@ using System.Linq; using System.Runtime.InteropServices; -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.Dotnet.Installation.Internal; internal static class DnupUtilities { diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs similarity index 84% rename from src/Installer/dnup/DotnetInstall.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs index 3dadd48884ef..ba31f78407c8 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs @@ -5,13 +5,13 @@ using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation; -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.Dotnet.Installation.Internal; /// /// Represents a .NET installation with a fully specified version. /// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. /// -public record DotnetInstall( +internal record DotnetInstall( DotnetInstallRoot InstallRoot, ReleaseVersion Version, InstallComponent Component); @@ -19,13 +19,13 @@ public record DotnetInstall( /// /// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. /// -public record DotnetInstallRequest( +internal record DotnetInstallRequest( DotnetInstallRoot InstallRoot, UpdateChannel Channel, InstallComponent Component, InstallRequestOptions Options); -public record InstallRequestOptions() +internal record InstallRequestOptions() { // Include things such as the custom feed here. } diff --git a/src/Installer/dnup/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs similarity index 99% rename from src/Installer/dnup/ReleaseManifest.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 5bb2e8ae002b..162c2b8e80b2 100644 --- a/src/Installer/dnup/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -12,7 +12,7 @@ using System.Threading.Tasks; using Microsoft.Deployment.DotNet.Releases; -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.Dotnet.Installation.Internal; /// /// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs similarity index 92% rename from src/Installer/dnup/ScopedMutex.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs index de8c1cb202d2..a9e63db0e70d 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs @@ -4,7 +4,7 @@ using System; using System.Threading; -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.Dotnet.Installation.Internal; public class ScopedMutex : IDisposable { diff --git a/src/Installer/dnup/SpectreDownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs similarity index 96% rename from src/Installer/dnup/SpectreDownloadProgressReporter.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs index f943e140e254..7301ffb16dc6 100644 --- a/src/Installer/dnup/SpectreDownloadProgressReporter.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs @@ -1,7 +1,7 @@ using System; using Spectre.Console; -namespace Microsoft.DotNet.Tools.Bootstrapper +namespace Microsoft.Dotnet.Installation.Internal { public class SpectreDownloadProgressReporter : IProgress { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs new file mode 100644 index 000000000000..6c66d0a58d4e --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal class UpdateChannel +{ + public string Name { get; set; } + + public UpdateChannel(string name) + { + Name = name; + } + + public bool IsFullySpecifiedVersion() + { + return ReleaseVersion.TryParse(Name, out _); + } + +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj index 4b8baf27e21d..b2c706ac179e 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 0a7fee13c49f..8367577ed77e 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs index 8eaa946819f1..ffa19fa6b8de 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net.Http; using System.Threading; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Spectre.Console; diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 4dd2a6355aa8..1c880fdf5556 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -10,6 +10,7 @@ using SpectreAnsiConsole = Spectre.Console.AnsiConsole; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 4db94209daee..40fd57b79d95 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using System.Collections.Generic; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper { @@ -13,5 +14,5 @@ namespace Microsoft.DotNet.Tools.Bootstrapper [JsonSerializable(typeof(InstallArchitecture))] [JsonSerializable(typeof(InstallType))] [JsonSerializable(typeof(ManagementCadence))] - public partial class DnupManifestJsonContext : JsonSerializerContext { } + internal partial class DnupManifestJsonContext : JsonSerializerContext { } } diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 62ef6aee00b8..574244bf1bf0 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -7,6 +7,7 @@ using System.IO; using System.Text.Json; using System.Threading; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dnup/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs index d63073f1184b..e43cc5a46e98 100644 --- a/src/Installer/dnup/DotnetInstallManager.cs +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; using Spectre.Console; diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs index 4aa56b20dcb6..b17c026fa4d0 100644 --- a/src/Installer/dnup/IDnupManifest.cs +++ b/src/Installer/dnup/IDnupManifest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/IInstallationValidator.cs b/src/Installer/dnup/IInstallationValidator.cs index 3f9195021160..b49d87a1571c 100644 --- a/src/Installer/dnup/IInstallationValidator.cs +++ b/src/Installer/dnup/IInstallationValidator.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 091e2e12eb42..ab9fd0517787 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs index 506bed9cd416..2a8b24aceb49 100644 --- a/src/Installer/dnup/ManifestChannelVersionResolver.cs +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dnup/UpdateChannel.cs b/src/Installer/dnup/UpdateChannel.cs deleted file mode 100644 index fc25144ed5b0..000000000000 --- a/src/Installer/dnup/UpdateChannel.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.Deployment.DotNet.Releases; - -namespace Microsoft.DotNet.Tools.Bootstrapper -{ - public class UpdateChannel - { - public string Name { get; set; } - - public UpdateChannel(string name) - { - Name = name; - } - - public bool IsFullySpecifiedVersion() - { - return ReleaseVersion.TryParse(Name, out _); - } - - } -} From b3b7bbd95c68686b0cdf8bfa3543ed11116ecbe5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 17 Oct 2025 16:39:37 -0700 Subject: [PATCH 078/153] Add basic yml for running PR tests in dnup we will want to consolidate this into the SDK later. run pr ci on dnup I don't have permission to add a pipeline so let's try to filter them out I wish there was a built in method for yml to do this instead. For example, pr: has exclude which lets us not run the ci if changes are only in certain components. Ideally we could do something similar for the stages and condition them based on dnup/ non dnup changes? Note that we still need to have the .vsts-pr.yml file as the root pipeline file, and we can't just exclude running the entire thing as it controls the SDK pipeline as well. --- .vsts-dnup-pr.yml | 58 +++++++++++ .vsts-pr.yml | 97 +++++++++++++------ eng/pipelines/templates/jobs/dnup-tests.yml | 58 +++++++++++ .../templates/jobs/filter-dnup-changes.yml | 48 +++++++++ 4 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 .vsts-dnup-pr.yml create mode 100644 eng/pipelines/templates/jobs/dnup-tests.yml create mode 100644 eng/pipelines/templates/jobs/filter-dnup-changes.yml diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml new file mode 100644 index 000000000000..40cfafbe1e3a --- /dev/null +++ b/.vsts-dnup-pr.yml @@ -0,0 +1,58 @@ +trigger: + branches: + include: + - dnup + paths: + include: + - src/Installer/dnup/ + - test/dnup.Tests/ + - global.json + - .vsts-dnup-tests.yml + +parameters: +- name: enableArm64Job + displayName: Enables the ARM64 job + type: boolean + default: true + +variables: + +pr: + branches: + include: + - dnup + - main + paths: + include: + - src/Installer/dnup/ + - test/dnup.Tests/ + - global.json + - .vsts-dnup-tests.yml + +variables: + buildConfiguration: Release + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + +stages: +- stage: dnup_tests + displayName: dnup tests + jobs: + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + os: windows + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals windows.vs2022.amd64.open + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + os: linux + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals build.ubuntu.2204.amd64.open + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + os: macOS + pool: + name: Azure Pipelines + vmImage: macOS-latest diff --git a/.vsts-pr.yml b/.vsts-pr.yml index a0ce56e66843..d7f272aec1d8 100644 --- a/.vsts-pr.yml +++ b/.vsts-pr.yml @@ -8,6 +8,7 @@ pr: - main - release/* - internal/release/* + - dnup paths: exclude: - documentation/* @@ -24,6 +25,9 @@ variables: - template: /eng/pipelines/templates/variables/sdk-defaults.yml # Variables used: DncEngPublicBuildPool - template: /eng/common/templates/variables/pool-providers.yml +# Variable to determine if PR changes are limited to dnup paths +- name: isDnupOnlyChanges + value: $[in(variables['Build.Reason'], 'PullRequest', 'Manual')] resources: containers: @@ -31,49 +35,88 @@ resources: image: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-build-amd64 stages: -############### BUILD STAGE ############### -- stage: build - displayName: Build +############### FILTER CHANGES STAGE ############### +- stage: filter_changes + displayName: Filter PR Changes jobs: - ############### WINDOWS ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + - template: /eng/pipelines/templates/jobs/filter-dnup-changes.yml parameters: pool: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals windows.vs2022.amd64.open + +############### BUILD STAGE ############### +- stage: build + displayName: Build + dependsOn: filter_changes + variables: + isDnupOnlyChanges: $[ stageDependencies.filter_changes.FilterDnupChanges.outputs['SetDnupFlag.isDnupOnlyChanges'] ] + jobs: + # If changes are dnup-only, run only dnup tests + - ${{ if eq(variables['isDnupOnlyChanges'], 'true') }}: + ############### DNUP TESTS ONLY ############### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals windows.vs2022.amd64.open os: windows - helixTargetQueue: windows.amd64.vs2022.pre.open - ############### LINUX ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml - parameters: - pool: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2204.amd64.open + - template: /eng/pipelines/templates/jobs/dnup-tests.yml + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals build.ubuntu.2204.amd64.open os: linux - helixTargetQueue: ubuntu.2204.amd64.open - ############### MACOS ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml - parameters: - pool: - name: Azure Pipelines - vmImage: macOS-latest + - template: /eng/pipelines/templates/jobs/dnup-tests.yml + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest os: macOS - helixTargetQueue: osx.15.amd64.open - ### ARM64 ### - - ${{ if eq(parameters.enableArm64Job, true) }}: + + # Otherwise run the full SDK matrix + - ${{ if ne(variables['isDnupOnlyChanges'], 'true') }}: + ############### WINDOWS ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals windows.vs2022.amd64.open + os: windows + helixTargetQueue: windows.amd64.vs2022.pre.open + + ############### LINUX ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals build.ubuntu.2204.amd64.open + os: linux + helixTargetQueue: ubuntu.2204.amd64.open + + ############### MACOS ############### - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml parameters: pool: name: Azure Pipelines vmImage: macOS-latest os: macOS - helixTargetQueue: osx.13.arm64.open - macOSJobParameterSets: - - categoryName: TestBuild - targetArchitecture: arm64 - runtimeIdentifier: osx-arm64 + helixTargetQueue: osx.15.amd64.open + ### ARM64 ### + - ${{ if eq(parameters.enableArm64Job, true) }}: + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + helixTargetQueue: osx.13.arm64.open + macOSJobParameterSets: + - categoryName: TestBuild + targetArchitecture: arm64 + runtimeIdentifier: osx-arm64 ############### DOTNET-FORMAT ############### - template: /eng/dotnet-format/dotnet-format-integration.yml diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml new file mode 100644 index 000000000000..1a265154b479 --- /dev/null +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -0,0 +1,58 @@ +parameters: + # Basic parameters mirroring style of other job templates + pool: {} + categoryName: dnupTests + dependsOn: '' + variables: {} + oneESCompat: + templateFolderName: templates + publishTaskPrefix: '' + timeoutInMinutes: 60 + os: windows + +jobs: +- job: ${{ parameters.categoryName }}_${{ parameters.os }} + displayName: 'dnup tests: ${{ parameters.os }}' + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + pool: ${{ parameters.pool }} + dependsOn: ${{ parameters.dependsOn }} + variables: + - ${{ insert }}: ${{ parameters.variables }} + steps: + - ${{ if eq(parameters.os, 'windows') }}: + - powershell: | + Write-Host "Restoring dnup tests" + dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: Restore + - powershell: | + dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + displayName: Build + - powershell: | + dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: Test + - ${{ if ne(parameters.os, 'windows') }}: + - script: | + echo "Restoring dnup tests" + dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: Restore + - script: | + dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + displayName: Build + - script: | + dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: Test + - task: PublishTestResults@2 + displayName: Publish dnup test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: '**/dnup-tests.trx' + searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults + testRunTitle: 'dnup ${{ parameters.os }}' + - task: PublishBuildArtifacts@1 + displayName: Publish dnup test artifacts + condition: always() + inputs: + PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults + ArtifactName: dnupTestResults_${{ parameters.os }} + publishLocation: Container diff --git a/eng/pipelines/templates/jobs/filter-dnup-changes.yml b/eng/pipelines/templates/jobs/filter-dnup-changes.yml new file mode 100644 index 000000000000..4e2152ac9aa3 --- /dev/null +++ b/eng/pipelines/templates/jobs/filter-dnup-changes.yml @@ -0,0 +1,48 @@ +# Filter job to check if changes are dnup-only +parameters: + pool: {} + +jobs: +- job: FilterDnupChanges + displayName: Filter dnup-only Changes + pool: ${{ parameters.pool }} + + steps: + - checkout: self + fetchDepth: 0 + + - powershell: | + # Initialize the variable to false + $isDnupOnly = $true + + # If PR, get the changed files + if ($env:BUILD_REASON -eq 'PullRequest') { + Write-Host "This is a PR build, checking changed files..." + $targetBranch = "$env:SYSTEM_PULLREQUEST_TARGETBRANCH" + Write-Host "Target branch: $targetBranch" + + # Get changed files in the PR + $changedFiles = git diff --name-only origin/$targetBranch..HEAD + + Write-Host "Changed files:" + $changedFiles | ForEach-Object { Write-Host " $_" } + + # Check if any changed files are outside dnup paths + foreach ($file in $changedFiles) { + if (-not ($file.StartsWith("src/Installer/") -or $file.StartsWith("test/dnup.Tests/"))) { + Write-Host "Found change outside dnup paths: $file" + $isDnupOnly = $false + break + } + } + } else { + # If not a PR, we assume it's not dnup-only + Write-Host "This is not a PR build, setting isDnupOnly to false" + $isDnupOnly = $false + } + + # Set the variable for later stages + Write-Host "##vso[task.setvariable variable=isDnupOnlyChanges;isOutput=true]$isDnupOnly" + Write-Host "isDnupOnlyChanges = $isDnupOnly" + displayName: Check if changes are dnup-only + name: SetDnupFlag From 1dd19aeec576d61d010dc67bf05e9b4543cb63eb Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 11:18:23 -0700 Subject: [PATCH 079/153] Remove modifications to main CI Working with the engineering system team to make a new pipeline in azdo since I cannot. adding this bloat to the main SDK pipeline is not the ideal approach. --- .vsts-pr.yml | 97 ++++++------------- .../templates/jobs/filter-dnup-changes.yml | 48 --------- 2 files changed, 27 insertions(+), 118 deletions(-) delete mode 100644 eng/pipelines/templates/jobs/filter-dnup-changes.yml diff --git a/.vsts-pr.yml b/.vsts-pr.yml index d7f272aec1d8..a0ce56e66843 100644 --- a/.vsts-pr.yml +++ b/.vsts-pr.yml @@ -8,7 +8,6 @@ pr: - main - release/* - internal/release/* - - dnup paths: exclude: - documentation/* @@ -25,9 +24,6 @@ variables: - template: /eng/pipelines/templates/variables/sdk-defaults.yml # Variables used: DncEngPublicBuildPool - template: /eng/common/templates/variables/pool-providers.yml -# Variable to determine if PR changes are limited to dnup paths -- name: isDnupOnlyChanges - value: $[in(variables['Build.Reason'], 'PullRequest', 'Manual')] resources: containers: @@ -35,88 +31,49 @@ resources: image: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-build-amd64 stages: -############### FILTER CHANGES STAGE ############### -- stage: filter_changes - displayName: Filter PR Changes +############### BUILD STAGE ############### +- stage: build + displayName: Build jobs: - - template: /eng/pipelines/templates/jobs/filter-dnup-changes.yml + ############### WINDOWS ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self parameters: pool: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals windows.vs2022.amd64.open - -############### BUILD STAGE ############### -- stage: build - displayName: Build - dependsOn: filter_changes - variables: - isDnupOnlyChanges: $[ stageDependencies.filter_changes.FilterDnupChanges.outputs['SetDnupFlag.isDnupOnlyChanges'] ] - jobs: - # If changes are dnup-only, run only dnup tests - - ${{ if eq(variables['isDnupOnlyChanges'], 'true') }}: - ############### DNUP TESTS ONLY ############### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml - parameters: - pool: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals windows.vs2022.amd64.open os: windows + helixTargetQueue: windows.amd64.vs2022.pre.open - - template: /eng/pipelines/templates/jobs/dnup-tests.yml - parameters: - pool: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2204.amd64.open + ############### LINUX ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals build.ubuntu.2204.amd64.open os: linux + helixTargetQueue: ubuntu.2204.amd64.open - - template: /eng/pipelines/templates/jobs/dnup-tests.yml - parameters: - pool: - name: Azure Pipelines - vmImage: macOS-latest + ############### MACOS ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest os: macOS - - # Otherwise run the full SDK matrix - - ${{ if ne(variables['isDnupOnlyChanges'], 'true') }}: - ############### WINDOWS ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self - parameters: - pool: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals windows.vs2022.amd64.open - os: windows - helixTargetQueue: windows.amd64.vs2022.pre.open - - ############### LINUX ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml - parameters: - pool: - name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2204.amd64.open - os: linux - helixTargetQueue: ubuntu.2204.amd64.open - - ############### MACOS ############### + helixTargetQueue: osx.15.amd64.open + ### ARM64 ### + - ${{ if eq(parameters.enableArm64Job, true) }}: - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml parameters: pool: name: Azure Pipelines vmImage: macOS-latest os: macOS - helixTargetQueue: osx.15.amd64.open - ### ARM64 ### - - ${{ if eq(parameters.enableArm64Job, true) }}: - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml - parameters: - pool: - name: Azure Pipelines - vmImage: macOS-latest - os: macOS - helixTargetQueue: osx.13.arm64.open - macOSJobParameterSets: - - categoryName: TestBuild - targetArchitecture: arm64 - runtimeIdentifier: osx-arm64 + helixTargetQueue: osx.13.arm64.open + macOSJobParameterSets: + - categoryName: TestBuild + targetArchitecture: arm64 + runtimeIdentifier: osx-arm64 ############### DOTNET-FORMAT ############### - template: /eng/dotnet-format/dotnet-format-integration.yml diff --git a/eng/pipelines/templates/jobs/filter-dnup-changes.yml b/eng/pipelines/templates/jobs/filter-dnup-changes.yml deleted file mode 100644 index 4e2152ac9aa3..000000000000 --- a/eng/pipelines/templates/jobs/filter-dnup-changes.yml +++ /dev/null @@ -1,48 +0,0 @@ -# Filter job to check if changes are dnup-only -parameters: - pool: {} - -jobs: -- job: FilterDnupChanges - displayName: Filter dnup-only Changes - pool: ${{ parameters.pool }} - - steps: - - checkout: self - fetchDepth: 0 - - - powershell: | - # Initialize the variable to false - $isDnupOnly = $true - - # If PR, get the changed files - if ($env:BUILD_REASON -eq 'PullRequest') { - Write-Host "This is a PR build, checking changed files..." - $targetBranch = "$env:SYSTEM_PULLREQUEST_TARGETBRANCH" - Write-Host "Target branch: $targetBranch" - - # Get changed files in the PR - $changedFiles = git diff --name-only origin/$targetBranch..HEAD - - Write-Host "Changed files:" - $changedFiles | ForEach-Object { Write-Host " $_" } - - # Check if any changed files are outside dnup paths - foreach ($file in $changedFiles) { - if (-not ($file.StartsWith("src/Installer/") -or $file.StartsWith("test/dnup.Tests/"))) { - Write-Host "Found change outside dnup paths: $file" - $isDnupOnly = $false - break - } - } - } else { - # If not a PR, we assume it's not dnup-only - Write-Host "This is not a PR build, setting isDnupOnly to false" - $isDnupOnly = $false - } - - # Set the variable for later stages - Write-Host "##vso[task.setvariable variable=isDnupOnlyChanges;isOutput=true]$isDnupOnly" - Write-Host "isDnupOnlyChanges = $isDnupOnly" - displayName: Check if changes are dnup-only - name: SetDnupFlag From d284845897b3c4345d9a59f48e80e1bf88a183d2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 11:54:01 -0700 Subject: [PATCH 080/153] Improve yml file for dnup test runs --- .vsts-dnup-pr.yml | 50 +++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 40cfafbe1e3a..3c9c2d38aca2 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -1,4 +1,8 @@ -trigger: +# Pipeline: https://dev.azure.com/dnceng-public/public/ + +trigger: none + +pr: branches: include: - dnup @@ -16,43 +20,51 @@ parameters: default: true variables: - -pr: - branches: - include: - - dnup - - main - paths: - include: - - src/Installer/dnup/ - - test/dnup.Tests/ - - global.json - - .vsts-dnup-tests.yml - -variables: - buildConfiguration: Release +- template: /eng/pipelines/templates/variables/sdk-defaults.yml + # Variables used: DncEngPublicBuildPool +- template: /eng/common/templates/variables/pool-providers.yml DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_CLI_TELEMETRY_OPTOUT: 1 stages: -- stage: dnup_tests - displayName: dnup tests +- stage: dnup + displayName: 🔎 dnup tests jobs: + ############### WINDOWS ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: - os: windows pool: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals windows.vs2022.amd64.open + os: windows + + ############### LINUX ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: os: linux pool: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals build.ubuntu.2204.amd64.open + + ############### MACOS ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: os: macOS pool: name: Azure Pipelines vmImage: macOS-latest + + ### ARM64 ### + - ${{ if eq(parameters.enableArm64Job, true) }}: + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + helixTargetQueue: osx.13.arm64.open + macOSJobParameterSets: + - categoryName: TestBuild + targetArchitecture: arm64 + runtimeIdentifier: osx-arm64 + From c1bfc12c7febe21b7d88be25cce4270155de6977 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:26:49 -0700 Subject: [PATCH 081/153] Fix formatting of pipeline --- .vsts-dnup-pr.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 3c9c2d38aca2..bddf9d17e973 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -1,17 +1,17 @@ -# Pipeline: https://dev.azure.com/dnceng-public/public/ +# Pipeline: https://dev.azure.com/dnceng-public/public/_build?definitionId=323 trigger: none pr: branches: include: - - dnup + - dnup paths: include: - - src/Installer/dnup/ - - test/dnup.Tests/ - - global.json - - .vsts-dnup-tests.yml + - src/Installer/dnup/ + - test/dnup.Tests/ + - global.json + - .vsts-dnup-tests.yml parameters: - name: enableArm64Job @@ -23,8 +23,6 @@ variables: - template: /eng/pipelines/templates/variables/sdk-defaults.yml # Variables used: DncEngPublicBuildPool - template: /eng/common/templates/variables/pool-providers.yml - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - DOTNET_CLI_TELEMETRY_OPTOUT: 1 stages: - stage: dnup @@ -37,22 +35,25 @@ stages: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals windows.vs2022.amd64.open os: windows + helixTargetQueue: windows.amd64.vs2022.pre.open ############### LINUX ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: - os: linux pool: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals build.ubuntu.2204.amd64.open + os: linux + helixTargetQueue: ubuntu.2204.amd64.open ############### MACOS ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: - os: macOS pool: name: Azure Pipelines vmImage: macOS-latest + os: macOS + helixTargetQueue: osx.15.amd64.open ### ARM64 ### - ${{ if eq(parameters.enableArm64Job, true) }}: From d3d247e58912e39f59b2b2da63ebda360360e667 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:28:21 -0700 Subject: [PATCH 082/153] Fix yml whitespace --- .vsts-dnup-pr.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index bddf9d17e973..9065ebbe7fc3 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -56,7 +56,7 @@ stages: helixTargetQueue: osx.15.amd64.open ### ARM64 ### - - ${{ if eq(parameters.enableArm64Job, true) }}: + - ${{ if eq(parameters.enableArm64Job, true) }}: - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml parameters: pool: @@ -69,3 +69,4 @@ stages: targetArchitecture: arm64 runtimeIdentifier: osx-arm64 + From ecba450e5c108084210b006069fd54a708dd7123 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:39:26 -0700 Subject: [PATCH 083/153] Try to use oneES Job Template --- eng/pipelines/templates/jobs/dnup-tests.yml | 49 +++++++++++++-------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 1a265154b479..e51b8e9c0adf 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -1,25 +1,38 @@ parameters: - # Basic parameters mirroring style of other job templates - pool: {} - categoryName: dnupTests + pool: '' dependsOn: '' - variables: {} + categoryName: dnupTests + variables: [] oneESCompat: templateFolderName: templates - publishTaskPrefix: '' - timeoutInMinutes: 60 - os: windows + ### ARCADE ### + preSteps: [] jobs: -- job: ${{ parameters.categoryName }}_${{ parameters.os }} - displayName: 'dnup tests: ${{ parameters.os }}' - timeoutInMinutes: ${{ parameters.timeoutInMinutes }} - pool: ${{ parameters.pool }} - dependsOn: ${{ parameters.dependsOn }} - variables: - - ${{ insert }}: ${{ parameters.variables }} +- template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml + parameters: + displayName: 'dnup tests: ${{ parameters.pool.os }}' + pool: ${{ parameters.pool }} + container: ${{ parameters.container }} + strategy: ${{ parameters.strategy }} + helixRepo: dotnet/sdk + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + enableMicrobuild: true + enablePublishBuildAssets: true + enableTelemetry: true + enablePublishUsingPipelines: true + enableSbom: ${{ parameters.enableSbom }} + variables: + - ${{ insert }}: ${{ parameters.variables }} + dependsOn: ${{ parameters.dependsOn }} + preSteps: ${{ parameters.preSteps }} + templateContext: + sdl: + binskim: + analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; + steps: - - ${{ if eq(parameters.os, 'windows') }}: + - ${{ if eq(parameters.pool.os, 'windows') }}: - powershell: | Write-Host "Restoring dnup tests" dotnet restore test/dnup.Tests/dnup.Tests.csproj @@ -30,7 +43,7 @@ jobs: - powershell: | dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults displayName: Test - - ${{ if ne(parameters.os, 'windows') }}: + - ${{ if ne(parameters.pool.os, 'windows') }}: - script: | echo "Restoring dnup tests" dotnet restore test/dnup.Tests/dnup.Tests.csproj @@ -48,11 +61,11 @@ jobs: testResultsFormat: VSTest testResultsFiles: '**/dnup-tests.trx' searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults - testRunTitle: 'dnup ${{ parameters.os }}' + testRunTitle: 'dnup ${{ parameters.pool.os }}' - task: PublishBuildArtifacts@1 displayName: Publish dnup test artifacts condition: always() inputs: PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults - ArtifactName: dnupTestResults_${{ parameters.os }} + ArtifactName: dnupTestResults_${{ parameters.pool.os }} publishLocation: Container From 10a759592945bc59fe1e63e91fc24aab1136155d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:40:11 -0700 Subject: [PATCH 084/153] fix whitespace --- eng/pipelines/templates/jobs/dnup-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index e51b8e9c0adf..ae143910a6f5 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -10,7 +10,7 @@ parameters: jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml - parameters: + parameters: displayName: 'dnup tests: ${{ parameters.pool.os }}' pool: ${{ parameters.pool }} container: ${{ parameters.container }} From 89877da681cf8bdfb5dc59211db39d96a9a7c734 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:40:41 -0700 Subject: [PATCH 085/153] fix whitespace --- eng/pipelines/templates/jobs/dnup-tests.yml | 76 ++++++++++----------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index ae143910a6f5..5d4dacf0000d 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -31,41 +31,41 @@ jobs: binskim: analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; - steps: - - ${{ if eq(parameters.pool.os, 'windows') }}: - - powershell: | - Write-Host "Restoring dnup tests" - dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: Restore - - powershell: | - dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore - displayName: Build - - powershell: | - dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults - displayName: Test - - ${{ if ne(parameters.pool.os, 'windows') }}: - - script: | - echo "Restoring dnup tests" - dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: Restore - - script: | - dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore - displayName: Build - - script: | - dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults - displayName: Test - - task: PublishTestResults@2 - displayName: Publish dnup test results - condition: always() - inputs: - testResultsFormat: VSTest - testResultsFiles: '**/dnup-tests.trx' - searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults - testRunTitle: 'dnup ${{ parameters.pool.os }}' - - task: PublishBuildArtifacts@1 - displayName: Publish dnup test artifacts - condition: always() - inputs: - PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults - ArtifactName: dnupTestResults_${{ parameters.pool.os }} - publishLocation: Container + steps: + - ${{ if eq(parameters.pool.os, 'windows') }}: + - powershell: | + Write-Host "Restoring dnup tests" + dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: Restore + - powershell: | + dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + displayName: Build + - powershell: | + dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: Test + - ${{ if ne(parameters.pool.os, 'windows') }}: + - script: | + echo "Restoring dnup tests" + dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: Restore + - script: | + dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + displayName: Build + - script: | + dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: Test + - task: PublishTestResults@2 + displayName: Publish dnup test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: '**/dnup-tests.trx' + searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults + testRunTitle: 'dnup ${{ parameters.pool.os }}' + - task: PublishBuildArtifacts@1 + displayName: Publish dnup test artifacts + condition: always() + inputs: + PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults + ArtifactName: dnupTestResults_${{ parameters.pool.os }} + publishLocation: Container From b324ba68bc1700c6ba4ac88577d14eca3907143f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:42:48 -0700 Subject: [PATCH 086/153] Fix parameters --- eng/pipelines/templates/jobs/dnup-tests.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 5d4dacf0000d..09705812570c 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -1,12 +1,18 @@ parameters: - pool: '' + ### GENERAL ### + variables: {} dependsOn: '' - categoryName: dnupTests - variables: [] oneESCompat: templateFolderName: templates - ### ARCADE ### - preSteps: [] + publishTaskPrefix: '' + container: '' + helixTargetContainer: '' + categoryName: dnup + runTests: true + publishRetryConfig: false + publishXunitResults: false + enableSbom: true + timeoutInMinutes: 150 jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml From 884515db78c1ae30c561b8a5aab06ea7010b1147 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:47:23 -0700 Subject: [PATCH 087/153] Update test names and use proper arm64 test template --- .vsts-dnup-pr.yml | 18 +++++++----------- eng/pipelines/templates/jobs/dnup-tests.yml | 16 ++++++++-------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 9065ebbe7fc3..75243026f570 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -57,16 +57,12 @@ stages: ### ARM64 ### - ${{ if eq(parameters.enableArm64Job, true) }}: - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml - parameters: - pool: - name: Azure Pipelines - vmImage: macOS-latest - os: macOS - helixTargetQueue: osx.13.arm64.open - macOSJobParameterSets: - - categoryName: TestBuild - targetArchitecture: arm64 - runtimeIdentifier: osx-arm64 + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + helixTargetQueue: osx.13.arm64.open diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 09705812570c..6baa83d9335e 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -42,26 +42,26 @@ jobs: - powershell: | Write-Host "Restoring dnup tests" dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: Restore + displayName: ♻️ Restore on Windows - powershell: | dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore - displayName: Build + displayName: 💻 Build Windows - powershell: | dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults - displayName: Test + displayName: 🔍 Test Windows - ${{ if ne(parameters.pool.os, 'windows') }}: - script: | echo "Restoring dnup tests" dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: Restore + displayName: ♻️ Restore on Unix - script: | dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore - displayName: Build + displayName: 🐧 Build Mac and Linux - script: | dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults - displayName: Test + displayName: 🔎 Test Mac and Linux - task: PublishTestResults@2 - displayName: Publish dnup test results + displayName: 🚀 Publish test results condition: always() inputs: testResultsFormat: VSTest @@ -69,7 +69,7 @@ jobs: searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults testRunTitle: 'dnup ${{ parameters.pool.os }}' - task: PublishBuildArtifacts@1 - displayName: Publish dnup test artifacts + displayName: ⬇️ Publish test artifacts condition: always() inputs: PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults From 46c898c9f147d1c23b96587905b0ea718e9f239e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:50:17 -0700 Subject: [PATCH 088/153] fix whitespace --- .vsts-dnup-pr.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 75243026f570..aaf3ed1c72bb 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -57,12 +57,12 @@ stages: ### ARM64 ### - ${{ if eq(parameters.enableArm64Job, true) }}: - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self - parameters: - pool: - name: Azure Pipelines - vmImage: macOS-latest - os: macOS - helixTargetQueue: osx.13.arm64.open + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + helixTargetQueue: osx.13.arm64.open From 39a088b58a836f2674aa98789180ed7a0ae3e21e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:53:02 -0700 Subject: [PATCH 089/153] Include helix target queue in name --- eng/pipelines/templates/jobs/dnup-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 6baa83d9335e..98bccd9bd497 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -2,6 +2,7 @@ parameters: ### GENERAL ### variables: {} dependsOn: '' + helixTargetQueue: '' oneESCompat: templateFolderName: templates publishTaskPrefix: '' @@ -18,6 +19,7 @@ jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml parameters: displayName: 'dnup tests: ${{ parameters.pool.os }}' + displayName: 'dnup tests: ${{ parameters.pool.os }} ({{ parameters.helixTargetQueue }})' pool: ${{ parameters.pool }} container: ${{ parameters.container }} strategy: ${{ parameters.strategy }} From 74f82d5d1088e08c63c655db019fedf556d2c946 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:53:12 -0700 Subject: [PATCH 090/153] fix merge --- eng/pipelines/templates/jobs/dnup-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 98bccd9bd497..395c0e6c008f 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -18,7 +18,6 @@ parameters: jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml parameters: - displayName: 'dnup tests: ${{ parameters.pool.os }}' displayName: 'dnup tests: ${{ parameters.pool.os }} ({{ parameters.helixTargetQueue }})' pool: ${{ parameters.pool }} container: ${{ parameters.container }} From f0461129dd42795c81c98da1faf4bd32bd64b892 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:54:10 -0700 Subject: [PATCH 091/153] Try to use .dotnet local SDK for .NET 10 SDK --- eng/pipelines/templates/jobs/dnup-tests.yml | 28 +++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 395c0e6c008f..4ecd33d91047 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -41,25 +41,33 @@ jobs: steps: - ${{ if eq(parameters.pool.os, 'windows') }}: - powershell: | - Write-Host "Restoring dnup tests" - dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: ♻️ Restore on Windows + & .\restore.cmd + displayName: ♻️ Bootstrap toolset (Windows) - powershell: | - dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj + displayName: ♻️ Restore dnup tests (Windows) + - powershell: | + & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore displayName: 💻 Build Windows - powershell: | - dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + & .\.dotnet\dotnet test test\dnup.Tests\dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults displayName: 🔍 Test Windows - ${{ if ne(parameters.pool.os, 'windows') }}: - script: | - echo "Restoring dnup tests" - dotnet restore test/dnup.Tests/dnup.Tests.csproj - displayName: ♻️ Restore on Unix + set -e + ./restore.sh + displayName: ♻️ Bootstrap toolset (Unix) + - script: | + set -e + ./.dotnet/dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: ♻️ Restore dnup tests (Unix) - script: | - dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + set -e + ./.dotnet/dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore displayName: 🐧 Build Mac and Linux - script: | - dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + set -e + ./.dotnet/dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults displayName: 🔎 Test Mac and Linux - task: PublishTestResults@2 displayName: 🚀 Publish test results From 58c6e0e6b8ca2326d687bce567863449ebdcd225 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:55:25 -0700 Subject: [PATCH 092/153] fix missing $ in yml --- eng/pipelines/templates/jobs/dnup-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 4ecd33d91047..2ee5c333e3dd 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -18,7 +18,7 @@ parameters: jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml parameters: - displayName: 'dnup tests: ${{ parameters.pool.os }} ({{ parameters.helixTargetQueue }})' + displayName: 'dnup tests: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})' pool: ${{ parameters.pool }} container: ${{ parameters.container }} strategy: ${{ parameters.strategy }} From eb2e6f4953485b621d5f58fe3d04fe0422f6bcb0 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 13:57:06 -0700 Subject: [PATCH 093/153] Use different separator for bootstrap step --- eng/pipelines/templates/jobs/dnup-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 2ee5c333e3dd..dab61e3b41f2 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -42,7 +42,7 @@ jobs: - ${{ if eq(parameters.pool.os, 'windows') }}: - powershell: | & .\restore.cmd - displayName: ♻️ Bootstrap toolset (Windows) + displayName: 🍱 Bootstrap toolset (Windows) - powershell: | & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj displayName: ♻️ Restore dnup tests (Windows) @@ -56,7 +56,7 @@ jobs: - script: | set -e ./restore.sh - displayName: ♻️ Bootstrap toolset (Unix) + displayName: 🍱 Bootstrap toolset (Unix) - script: | set -e ./.dotnet/dotnet restore test/dnup.Tests/dnup.Tests.csproj From adb611a71af0e046c8848f7acb5f3790bc8d4b69 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:01:26 -0700 Subject: [PATCH 094/153] Include EnvDetectionRule --- eng/pipelines/templates/jobs/dnup-tests.yml | 4 ++-- src/Installer/dnup/dnup.csproj | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index dab61e3b41f2..51591d4fae4a 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -64,11 +64,11 @@ jobs: - script: | set -e ./.dotnet/dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore - displayName: 🐧 Build Mac and Linux + displayName: 🐧 Build (Unix) - script: | set -e ./.dotnet/dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults - displayName: 🔎 Test Mac and Linux + displayName: 🔎 Test (Unix) - task: PublishTestResults@2 displayName: 🚀 Publish test results condition: always() diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index 5ef732fe7e6e..f17eff4311d0 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -21,6 +21,7 @@ + From 1efda88fe198f3e25f804fc3b7225a480c4833ef Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:06:42 -0700 Subject: [PATCH 095/153] Add unique identifier to build steps to make it easier to tell from afar --- .vsts-dnup-pr.yml | 4 ++++ eng/pipelines/templates/jobs/dnup-tests.yml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index aaf3ed1c72bb..ffc22ab69df1 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -35,6 +35,7 @@ stages: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals windows.vs2022.amd64.open os: windows + emoji: 🪟 helixTargetQueue: windows.amd64.vs2022.pre.open ############### LINUX ############### @@ -44,6 +45,7 @@ stages: name: $(DncEngPublicBuildPool) demands: ImageOverride -equals build.ubuntu.2204.amd64.open os: linux + emoji: 🐧 helixTargetQueue: ubuntu.2204.amd64.open ############### MACOS ############### @@ -53,6 +55,7 @@ stages: name: Azure Pipelines vmImage: macOS-latest os: macOS + emoji: 🍎 helixTargetQueue: osx.15.amd64.open ### ARM64 ### @@ -63,6 +66,7 @@ stages: name: Azure Pipelines vmImage: macOS-latest os: macOS + emoji: 💪 helixTargetQueue: osx.13.arm64.open diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 51591d4fae4a..c795617a7d81 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -18,7 +18,7 @@ parameters: jobs: - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml parameters: - displayName: 'dnup tests: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})' + displayName: '${{ parameters.pool.emoji }} dnup tests: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})' pool: ${{ parameters.pool }} container: ${{ parameters.container }} strategy: ${{ parameters.strategy }} From 0c3fb23456b40d451be8c0205256c17541f78408 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:21:07 -0700 Subject: [PATCH 096/153] Use a separate icon to separate build steps im not sure if I really like this - chose a fortress to represent 'guarding' the code by running tests.... is it really helpful? It might be if / when we have other test steps to differentiate and find the dnup test checks. --- .vsts-dnup-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index ffc22ab69df1..7b58f5f735c0 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -26,7 +26,7 @@ variables: stages: - stage: dnup - displayName: 🔎 dnup tests + displayName: 🏰 dnup tests jobs: ############### WINDOWS ############### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self From 0d5adcc45d6c74a861f352cb47805dc3b7b44e33 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:32:22 -0700 Subject: [PATCH 097/153] Don't use global/ in the mutex there is no concept of a global\ mutex prefix on unix which could be incorrect. On windows, this makes it so the mutex is not for the local user session but instead across all users. I'm not sure if this is needed but since we edit the system path, it might be. I will have to think about whether there will be issues with a higher-level admin user that has held onto a mutex and got stuck, and whether a lower level user could undo that. --- src/Installer/dnup/ScopedMutex.cs | 19 +++++++++++++-- test/dnup.Tests/Utilities/TestEnvironment.cs | 25 +++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs index de8c1cb202d2..333fc26c05ac 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/dnup/ScopedMutex.cs @@ -13,8 +13,23 @@ public class ScopedMutex : IDisposable public ScopedMutex(string name) { - _mutex = new Mutex(false, name); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(10), false); + try + { + // On Linux and Mac, "Global\" prefix doesn't work - strip it if present + string mutexName = name; + if (Environment.OSVersion.Platform != PlatformID.Win32NT && mutexName.StartsWith("Global\\")) + { + mutexName = mutexName.Substring(7); + } + + _mutex = new Mutex(false, mutexName); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(10), false); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not create or acquire mutex '{name}': {ex.Message}"); + throw ex; + } } public bool HasHandle => _hasHandle; diff --git a/test/dnup.Tests/Utilities/TestEnvironment.cs b/test/dnup.Tests/Utilities/TestEnvironment.cs index 1153598b479e..3046c443d41a 100644 --- a/test/dnup.Tests/Utilities/TestEnvironment.cs +++ b/test/dnup.Tests/Utilities/TestEnvironment.cs @@ -28,7 +28,18 @@ public TestEnvironment(string tempRoot, string installPath, string manifestPath) // Store original environment values to restore later _originalManifestPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); _originalDefaultInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH"); - _originalCurrentDirectory = Environment.CurrentDirectory; + + try + { + _originalCurrentDirectory = Environment.CurrentDirectory; + } + catch (Exception ex) + { + // If we can't get the current directory (which can happen in CI), + // use the temp directory as a fallback + _originalCurrentDirectory = tempRoot; + Console.WriteLine($"Warning: Could not get current directory: {ex.Message}. Using temp directory as fallback."); + } // Set test environment variables Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", manifestPath); @@ -40,8 +51,16 @@ public TestEnvironment(string tempRoot, string installPath, string manifestPath) public void Dispose() { - // Restore original environment - Environment.CurrentDirectory = _originalCurrentDirectory; + try + { + // Restore original environment + Environment.CurrentDirectory = _originalCurrentDirectory; + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not restore current directory: {ex.Message}"); + } + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", _originalManifestPath); Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", _originalDefaultInstallPath); From 92096154d59c9f482567a3de3a90768468655945 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:47:04 -0700 Subject: [PATCH 098/153] Wait for longer to acquire mutex 10 seconds may fail - concurrently the tests could take a while. lets not fail the test because other tests are running. --- src/Installer/dnup/Constants.cs | 1 + src/Installer/dnup/ScopedMutex.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Installer/dnup/Constants.cs b/src/Installer/dnup/Constants.cs index 007364d7b8bc..cc612544b292 100644 --- a/src/Installer/dnup/Constants.cs +++ b/src/Installer/dnup/Constants.cs @@ -15,6 +15,7 @@ public static class MutexNames { /// /// Mutex used during the final installation phase to protect the manifest file and extracting folder(s). + /// Mutex names MUST be valid file names on Unix. https://learn.microsoft.com/dotnet/api/system.threading.mutex.openexisting?view=net-9.0 /// public const string ModifyInstallationStates = "Global\\DnupFinalize"; } diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs index 333fc26c05ac..6ada5bc4ab5e 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/dnup/ScopedMutex.cs @@ -23,7 +23,7 @@ public ScopedMutex(string name) } _mutex = new Mutex(false, mutexName); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(10), false); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(120), false); } catch (Exception ex) { From b4af1acaca8117cf0415cab9bfdd2ec61f25c881 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:48:12 -0700 Subject: [PATCH 099/153] Preserve stacktrace with throw; --- src/Installer/dnup/ScopedMutex.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs index 6ada5bc4ab5e..e845cb885efa 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/dnup/ScopedMutex.cs @@ -28,7 +28,7 @@ public ScopedMutex(string name) catch (Exception ex) { Console.WriteLine($"Warning: Could not create or acquire mutex '{name}': {ex.Message}"); - throw ex; + throw; } } From 298ebaae70da125052bd26c45ee075fc2b5c689b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 14:56:25 -0700 Subject: [PATCH 100/153] Make test artifacts dir so it doesn't fail to publish if tests fail --- eng/pipelines/templates/jobs/dnup-tests.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index c795617a7d81..0477def2d581 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -49,24 +49,26 @@ jobs: - powershell: | & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore displayName: 💻 Build Windows + - powershell: | + New-Item -Path "$(Build.SourcesDirectory)/artifacts/dnupTestResults" -ItemType Directory -Force + displayName: 📁 Create test results directory (Windows) - powershell: | & .\.dotnet\dotnet test test\dnup.Tests\dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults displayName: 🔍 Test Windows - ${{ if ne(parameters.pool.os, 'windows') }}: - script: | - set -e ./restore.sh displayName: 🍱 Bootstrap toolset (Unix) - script: | - set -e ./.dotnet/dotnet restore test/dnup.Tests/dnup.Tests.csproj displayName: ♻️ Restore dnup tests (Unix) - script: | - set -e ./.dotnet/dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore displayName: 🐧 Build (Unix) - script: | - set -e + mkdir -p "$(Build.SourcesDirectory)/artifacts/dnupTestResults" + displayName: 📁 Create test results directory (Unix) + - script: | ./.dotnet/dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults displayName: 🔎 Test (Unix) - task: PublishTestResults@2 From 050d9814d41f3ab1aa39797ad1107c84f4057d04 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 15:39:55 -0700 Subject: [PATCH 101/153] Prefer checking if the thread holds the mutex rather than OpenExisting OpenExisting may run into challenges with security policies changing - let's not try to navigate that when we can track it ourselves. --- src/Installer/dnup/DnupSharedManifest.cs | 10 +++++----- src/Installer/dnup/ScopedMutex.cs | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 62ef6aee00b8..ba61eb51270e 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -44,13 +44,13 @@ private static string GetManifestPath() private void AssertHasFinalizationMutex() { - var mutex = Mutex.OpenExisting(Constants.MutexNames.ModifyInstallationStates); - if (!mutex.WaitOne(0)) + // Instead of attempting to reacquire the named mutex (which can create race conditions + // or accidentally succeed when we *don't* hold it), rely on the thread-local tracking + // implemented in ScopedMutex. This ensures we only assert based on a lock we actually obtained. + if (!ScopedMutex.CurrentThreadHoldsMutex) { - throw new InvalidOperationException("The dnup manifest was accessed while not holding the mutex."); + throw new InvalidOperationException("The dnup manifest was accessed without holding the installation state mutex."); } - mutex.ReleaseMutex(); - mutex.Dispose(); } public IEnumerable GetInstalledVersions(IInstallationValidator? validator = null) diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs index e845cb885efa..64252e5d0d91 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/dnup/ScopedMutex.cs @@ -10,6 +10,8 @@ public class ScopedMutex : IDisposable { private readonly Mutex _mutex; private bool _hasHandle; + // Track recursive holds on a per-thread basis so we can assert manifest access without re-acquiring. + private static readonly ThreadLocal _holdCount = new(() => 0); public ScopedMutex(string name) { @@ -24,6 +26,10 @@ public ScopedMutex(string name) _mutex = new Mutex(false, mutexName); _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(120), false); + if (_hasHandle) + { + _holdCount.Value = _holdCount.Value + 1; + } } catch (Exception ex) { @@ -33,12 +39,24 @@ public ScopedMutex(string name) } public bool HasHandle => _hasHandle; + public static bool CurrentThreadHoldsMutex => _holdCount.Value > 0; public void Dispose() { if (_hasHandle) { - _mutex.ReleaseMutex(); + try + { + _mutex.ReleaseMutex(); + } + finally + { + // Decrement hold count even if release throws. + if (_holdCount.Value > 0) + { + _holdCount.Value = _holdCount.Value - 1; + } + } } _mutex.Dispose(); } From da92d4463a64df6df5539547c4ea4a0b9e57c3c3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 15:58:25 -0700 Subject: [PATCH 102/153] Improve manifest tests --- test/dnup.Tests/DnupE2Etest.cs | 33 ++++++++++-------- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 14 +++----- test/dnup.Tests/Utilities/PathUtilities.cs | 34 ------------------- 3 files changed, 22 insertions(+), 59 deletions(-) delete mode 100644 test/dnup.Tests/Utilities/PathUtilities.cs diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 126d88f41ccb..38b8e6a356c1 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -66,15 +66,16 @@ public void Test(string channel) Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); // Verify the installation was properly recorded in the manifest - using var finalizeLock = new Microsoft.DotNet.Tools.Bootstrapper.ScopedMutex(Microsoft.DotNet.Tools.Bootstrapper.Constants.MutexNames.ModifyInstallationStates); - finalizeLock.HasHandle.Should().BeTrue(); - - var manifest = new Microsoft.DotNet.Tools.Bootstrapper.DnupSharedManifest(); - var installs = manifest.GetInstalledVersions(); + var installs = []; + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(); + installs = manifest.GetInstalledVersions(); + } installs.Should().NotBeEmpty(); - var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); + var matchingInstalls = installs.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); matchingInstalls.Should().ContainSingle(); var install = matchingInstalls[0]; @@ -121,14 +122,16 @@ public void TestReusesExistingInstall() int exitCode = Parser.Parse(args).Invoke(); exitCode.Should().Be(0); + var firstDnupInstalls = []; // Verify the installation was successful using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); - var installs = manifest.GetInstalledVersions(); - installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + firstDnupInstalls = manifest.GetInstalledVersions(); } + firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + // Now install the same SDK again and capture the console output using var consoleOutput = new ConsoleOutputCapture(); @@ -147,18 +150,18 @@ public void TestReusesExistingInstall() output.Should().NotContain("Downloading .NET SDK", "dnup should not attempt to download the SDK again"); + var matchingInstalls = []; // Verify the installation record in the manifest hasn't changed using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); var installs = manifest.GetInstalledVersions(); - var matchingInstalls = installs.Where(i => PathUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); - - // Should still only have one installation - matchingInstalls.Should().ContainSingle(); - - // And it should be for the specified version - matchingInstalls[0].Version.ToString().Should().Be(channel); + matchingInstalls = installs.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); } + + // Should still only have one installation + matchingInstalls.Should().ContainSingle(); + // And it should be for the specified version + matchingInstalls[0].Version.ToString().Should().Be(channel); } } diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 564399aa27b4..c0a683bfd37c 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -21,11 +21,10 @@ public static TestEnvironment CreateTestEnvironment() { string tempRoot = Path.Combine(Path.GetTempPath(), "dnup-e2e", Guid.NewGuid().ToString("N")); string installPath = Path.Combine(tempRoot, "dotnet-root"); - string manifestPath = Path.Combine(tempRoot, "manifest", "dnup_manifest.json"); + string manifestPath = Path.Combine(tempRoot, "dnup_manifest.json"); // Create necessary directories Directory.CreateDirectory(tempRoot); - Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); Directory.CreateDirectory(installPath); return new TestEnvironment(tempRoot, installPath, manifestPath); @@ -54,17 +53,12 @@ public static string[] BuildArguments(string channel, string installPath, bool d args.Add("--no-progress"); } - return args.ToArray(); + return [.. args]; } /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// - public static InstallArchitecture MapArchitecture(Architecture architecture) => architecture switch - { - Architecture.X86 => InstallArchitecture.x86, - Architecture.X64 => InstallArchitecture.x64, - Architecture.Arm64 => InstallArchitecture.arm64, - _ => throw new NotSupportedException($"Architecture {architecture} is not supported."), - }; + public static InstallArchitecture MapArchitecture(Architecture architecture) => + Microsoft.DotNet.Tools.Bootstrapper.DnupUtilities.GetInstallArchitecture(architecture); } diff --git a/test/dnup.Tests/Utilities/PathUtilities.cs b/test/dnup.Tests/Utilities/PathUtilities.cs deleted file mode 100644 index 7e793865b869..000000000000 --- a/test/dnup.Tests/Utilities/PathUtilities.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.IO; - -namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; - -/// -/// Utilities for working with file paths in tests -/// -internal static class PathUtilities -{ - /// - /// Compares two paths for equality, normalizing them first - /// - public static bool PathsEqual(string? first, string? second) - { - if (first == null && second == null) - { - return true; - } - - if (first == null || second == null) - { - return false; - } - - string normalizedFirst = Path.GetFullPath(first).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - string normalizedSecond = Path.GetFullPath(second).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - return string.Equals(normalizedFirst, normalizedSecond, StringComparison.OrdinalIgnoreCase); - } -} From 585e48e219384c4f94059f8c25328393bedd6164 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 19:01:31 -0400 Subject: [PATCH 103/153] Create public APIs in library for installation --- .../AssemblyInfo.cs | 2 +- .../InstallerFactory.cs | 17 +++++++++++++ ...Installer.cs => ArchiveDotnetExtractor.cs} | 4 ++-- .../Internal/DotnetInstaller.cs | 24 +++++++++++++++++++ .../dnup/InstallerOrchestratorSingleton.cs | 2 +- test/dnup.Tests/DnupE2Etest.cs | 3 ++- test/dnup.Tests/ReleaseManifestTests.cs | 1 + .../Utilities/UpdateChannelExtensions.cs | 1 + 8 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs rename src/Installer/Microsoft.Dotnet.Installation/Internal/{ArchiveDotnetInstaller.cs => ArchiveDotnetExtractor.cs} (99%) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs index 2e500c68657c..d90827d40301 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs @@ -7,4 +7,4 @@ using System.Text; [assembly: InternalsVisibleTo("dnup, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] - +[assembly: InternalsVisibleTo("dnup.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs new file mode 100644 index 000000000000..3b6e3559d5a9 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.Dotnet.Installation; + +public static class InstallerFactory +{ + public static IDotnetInstaller CreateInstaller() + { + return new DotnetInstaller(); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs similarity index 99% rename from src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs rename to src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs index 370698392616..13ac693f2096 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetInstaller.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -12,7 +12,7 @@ namespace Microsoft.Dotnet.Installation.Internal; -internal class ArchiveDotnetInstaller : IDisposable +internal class ArchiveDotnetExtractor : IDisposable { private readonly DotnetInstallRequest _request; private readonly ReleaseVersion _resolvedVersion; @@ -20,7 +20,7 @@ internal class ArchiveDotnetInstaller : IDisposable private string scratchDownloadDirectory; private string? _archivePath; - public ArchiveDotnetInstaller(DotnetInstallRequest request, ReleaseVersion resolvedVersion, bool noProgress = false) + public ArchiveDotnetExtractor(DotnetInstallRequest request, ReleaseVersion resolvedVersion, bool noProgress = false) { _request = request; _resolvedVersion = resolvedVersion; diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs new file mode 100644 index 000000000000..3d08a84050d6 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; +using Spectre.Console; + +namespace Microsoft.Dotnet.Installation.Internal +{ + internal class DotnetInstaller : IDotnetInstaller + { + public void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version) + { + var installRequest = new DotnetInstallRequest(dotnetRoot, new UpdateChannel(version.ToString()), component, new InstallRequestOptions()); + + using ArchiveDotnetExtractor installer = new(installRequest, version, noProgress: true); + installer.Prepare(); + installer.Commit(); + } + public void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); + } +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index ab9fd0517787..03217b56d733 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -50,7 +50,7 @@ private InstallerOrchestratorSingleton() } } - using ArchiveDotnetInstaller installer = new(installRequest, versionToInstall, noProgress); + using ArchiveDotnetExtractor installer = new(installRequest, versionToInstall, noProgress); installer.Prepare(); // Extract and commit the install to the directory diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 126d88f41ccb..40e914bcc5ef 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -11,6 +11,7 @@ using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; using Microsoft.Dotnet.Installation; using Xunit; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Dnup.Tests; @@ -66,7 +67,7 @@ public void Test(string channel) Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); // Verify the installation was properly recorded in the manifest - using var finalizeLock = new Microsoft.DotNet.Tools.Bootstrapper.ScopedMutex(Microsoft.DotNet.Tools.Bootstrapper.Constants.MutexNames.ModifyInstallationStates); + using var finalizeLock = new ScopedMutex(Microsoft.DotNet.Tools.Bootstrapper.Constants.MutexNames.ModifyInstallationStates); finalizeLock.HasHandle.Should().BeTrue(); var manifest = new Microsoft.DotNet.Tools.Bootstrapper.DnupSharedManifest(); diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 5bc665138b18..85e7cfcf8868 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -2,6 +2,7 @@ using Xunit; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Dnup.Tests { diff --git a/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs index d62aa226d964..7c03d84164ec 100644 --- a/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs +++ b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs @@ -4,6 +4,7 @@ using System; using Microsoft.Deployment.DotNet.Releases; using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; From 90b1cf24a4b7b7dc01c0af05bc34250d36fb2c3f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:05:06 -0700 Subject: [PATCH 104/153] Add manifest path as an option this would likely cause concurrency issues in tests if we just rely on the environment variable. --- .../Commands/Sdk/Install/SdkInstallCommand.cs | 11 +++++++++-- .../Sdk/Install/SdkInstallCommandParser.cs | 7 +++++++ src/Installer/dnup/DnupSharedManifest.cs | 17 ++++++++++++++--- src/Installer/dnup/DotnetInstall.cs | 3 ++- .../dnup/InstallerOrchestratorSingleton.cs | 14 ++++++++------ test/dnup.Tests/DnupE2Etest.cs | 8 ++++---- test/dnup.Tests/Utilities/DnupTestUtilities.cs | 10 +++++++++- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 799f5d780979..af0bf71adcf2 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -19,6 +19,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly string? _installPath = result.GetValue(SdkInstallCommandParser.InstallPathOption); private readonly bool? _setDefaultInstall = result.GetValue(SdkInstallCommandParser.SetDefaultInstallOption); private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); + private readonly string? _manifestPath = result.GetValue(SdkInstallCommandParser.ManifestPathOption); private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); private readonly bool _noProgress = result.GetValue(SdkInstallCommandParser.NoProgressOption); @@ -177,7 +178,10 @@ public override int Execute() new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), new UpdateChannel(resolvedChannel), InstallComponent.SDK, - new InstallRequestOptions()); + new InstallRequestOptions + { + ManifestPath = _manifestPath + }); var resolvedVersion = _channelVersionResolver.Resolve(installRequest); @@ -228,7 +232,10 @@ public override int Execute() new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), new UpdateChannel(additionalVersion), InstallComponent.SDK, - new InstallRequestOptions()); + new InstallRequestOptions + { + ManifestPath = _manifestPath + }); // Install the additional version with the same progress settings as the main installation DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs index 6af4aecab880..1b7c3e9c2a96 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -36,6 +36,12 @@ internal static class SdkInstallCommandParser DefaultValueFactory = r => null }; + public static readonly Option ManifestPathOption = new("--manifest-path") + { + HelpName = "MANIFEST_PATH", + Description = "Custom path to the manifest file for tracking .NET SDK installations", + }; + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; public static readonly Option NoProgressOption = CommonOptions.NoProgressOption; @@ -65,6 +71,7 @@ private static Command ConstructCommand() command.Options.Add(InstallPathOption); command.Options.Add(SetDefaultInstallOption); command.Options.Add(UpdateGlobalJsonOption); + command.Options.Add(ManifestPathOption); command.Options.Add(InteractiveOption); command.Options.Add(NoProgressOption); diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index ba61eb51270e..1e31330ca063 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -12,10 +12,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; internal class DnupSharedManifest : IDnupManifest { - private static string ManifestPath => GetManifestPath(); + private string ManifestPath => GetManifestPath(); - public DnupSharedManifest() + public DnupSharedManifest(string? manifestPath = null) { + _customManifestPath = manifestPath; EnsureManifestExists(); } @@ -28,14 +29,24 @@ private void EnsureManifestExists() } } - private static string GetManifestPath() + private string? _customManifestPath; + + private string GetManifestPath() { + // Use explicitly provided path first (constructor argument) + if (!string.IsNullOrEmpty(_customManifestPath)) + { + return _customManifestPath; + } + + // Fall back to environment variable override var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); if (!string.IsNullOrEmpty(overridePath)) { return overridePath; } + // Default location return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "dnup", diff --git a/src/Installer/dnup/DotnetInstall.cs b/src/Installer/dnup/DotnetInstall.cs index 3dadd48884ef..edd1efffde09 100644 --- a/src/Installer/dnup/DotnetInstall.cs +++ b/src/Installer/dnup/DotnetInstall.cs @@ -27,5 +27,6 @@ public record DotnetInstallRequest( public record InstallRequestOptions() { - // Include things such as the custom feed here. + // Include options such as the custom feed, manifest path, etc. + public string? ManifestPath { get; init; } } diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 091e2e12eb42..8d1afb782f9b 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -38,11 +38,13 @@ private InstallerOrchestratorSingleton() versionToInstall, installRequest.Component); + string? customManifestPath = installRequest.Options.ManifestPath; + // Check if the install already exists and we don't need to do anything // read write mutex only for manifest? using (var finalizeLock = modifyInstallStateMutex()) { - if (InstallAlreadyExists(install)) + if (InstallAlreadyExists(install, customManifestPath)) { Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); return install; @@ -65,7 +67,7 @@ private InstallerOrchestratorSingleton() ArchiveInstallationValidator validator = new(); if (validator.Validate(install)) { - DnupSharedManifest manifestManager = new(); + DnupSharedManifest manifestManager = new(installRequest.Options.ManifestPath); manifestManager.AddInstalledVersion(install); } else @@ -80,9 +82,9 @@ private InstallerOrchestratorSingleton() /// /// Gets the existing installs from the manifest. Must hold a mutex over the directory. /// - private IEnumerable GetExistingInstalls(DotnetInstallRoot installRoot) + private IEnumerable GetExistingInstalls(DotnetInstallRoot installRoot, string? customManifestPath = null) { - var manifestManager = new DnupSharedManifest(); + var manifestManager = new DnupSharedManifest(customManifestPath); // Use the overload that filters by muxer directory return manifestManager.GetInstalledVersions(installRoot); } @@ -90,9 +92,9 @@ private IEnumerable GetExistingInstalls(DotnetInstallRoot install /// /// Checks if the installation already exists. Must hold a mutex over the directory. /// - private bool InstallAlreadyExists(DotnetInstall install) + private bool InstallAlreadyExists(DotnetInstall install, string? customManifestPath = null) { - var existingInstalls = GetExistingInstalls(install.InstallRoot); + var existingInstalls = GetExistingInstalls(install.InstallRoot, customManifestPath); // Check if there's any existing installation that matches the version we're trying to install return existingInstalls.Any(existing => diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 38b8e6a356c1..945f60550308 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -57,8 +57,8 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); - // Execute the command - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath); + // Execute the command with explicit manifest path + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); int exitCode = Parser.Parse(args).Invoke(); exitCode.Should().Be(0); @@ -115,9 +115,9 @@ public void TestReusesExistingInstall() const string channel = "9.0.103"; using var testEnv = DnupTestUtilities.CreateTestEnvironment(); - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - // Execute dnup to install the SDK the first time + // Execute dnup to install the SDK the first time with explicit manifest path Console.WriteLine($"First installation of {channel}"); int exitCode = Parser.Parse(args).Invoke(); exitCode.Should().Be(0); diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index c0a683bfd37c..a2258486a8c1 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -26,6 +26,7 @@ public static TestEnvironment CreateTestEnvironment() // Create necessary directories Directory.CreateDirectory(tempRoot); Directory.CreateDirectory(installPath); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); return new TestEnvironment(tempRoot, installPath, manifestPath); } @@ -33,7 +34,7 @@ public static TestEnvironment CreateTestEnvironment() /// /// Builds command line arguments for dnup /// - public static string[] BuildArguments(string channel, string installPath, bool disableProgress = true) + public static string[] BuildArguments(string channel, string installPath, string? manifestPath = null, bool disableProgress = true) { var args = new List { @@ -47,6 +48,13 @@ public static string[] BuildArguments(string channel, string installPath, bool d args.Add("--interactive"); args.Add("false"); + // Add manifest path option if specified for test isolation + if (!string.IsNullOrEmpty(manifestPath)) + { + args.Add("--manifest-path"); + args.Add(manifestPath); + } + // Add no-progress option when running tests in parallel to avoid Spectre.Console exclusivity issues if (disableProgress) { From 91058e7dd3c86ed139bcb201f0b436a0367de49a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:07:34 -0700 Subject: [PATCH 105/153] Fix expressions --- test/dnup.Tests/DnupE2Etest.cs | 10 +++++----- test/dnup.Tests/Utilities/TestEnvironment.cs | 14 ++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 945f60550308..f78be03807a1 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -66,11 +66,11 @@ public void Test(string channel) Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); // Verify the installation was properly recorded in the manifest - var installs = []; + List installs = new(); using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); - installs = manifest.GetInstalledVersions(); + installs = manifest.GetInstalledVersions().ToList(); } installs.Should().NotBeEmpty(); @@ -122,12 +122,12 @@ public void TestReusesExistingInstall() int exitCode = Parser.Parse(args).Invoke(); exitCode.Should().Be(0); - var firstDnupInstalls = []; + List firstDnupInstalls = new(); // Verify the installation was successful using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); - firstDnupInstalls = manifest.GetInstalledVersions(); + firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); @@ -150,7 +150,7 @@ public void TestReusesExistingInstall() output.Should().NotContain("Downloading .NET SDK", "dnup should not attempt to download the SDK again"); - var matchingInstalls = []; + List matchingInstalls = new(); // Verify the installation record in the manifest hasn't changed using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { diff --git a/test/dnup.Tests/Utilities/TestEnvironment.cs b/test/dnup.Tests/Utilities/TestEnvironment.cs index 3046c443d41a..ef3af45e496e 100644 --- a/test/dnup.Tests/Utilities/TestEnvironment.cs +++ b/test/dnup.Tests/Utilities/TestEnvironment.cs @@ -11,8 +11,6 @@ namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; /// internal class TestEnvironment : IDisposable { - private readonly string? _originalManifestPath; - private readonly string? _originalDefaultInstallPath; private readonly string _originalCurrentDirectory; public string TempRoot { get; } @@ -25,10 +23,6 @@ public TestEnvironment(string tempRoot, string installPath, string manifestPath) InstallPath = installPath; ManifestPath = manifestPath; - // Store original environment values to restore later - _originalManifestPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); - _originalDefaultInstallPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH"); - try { _originalCurrentDirectory = Environment.CurrentDirectory; @@ -41,8 +35,8 @@ public TestEnvironment(string tempRoot, string installPath, string manifestPath) Console.WriteLine($"Warning: Could not get current directory: {ex.Message}. Using temp directory as fallback."); } - // Set test environment variables - Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", manifestPath); + // Set default install path as environment variable + // This is required for cases where the install path is needed but not explicitly provided Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", installPath); // Change current directory to the temp directory to avoid global.json in repository root @@ -61,8 +55,8 @@ public void Dispose() Console.WriteLine($"Warning: Could not restore current directory: {ex.Message}"); } - Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH", _originalManifestPath); - Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", _originalDefaultInstallPath); + // Clear the environment variable we set + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", null); // Clean up if (Directory.Exists(TempRoot)) From 0fe4fcdc7d630849c4fe4fe1c2452a256a900bee Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:13:27 -0700 Subject: [PATCH 106/153] don't consider 20s as a long test - installing in an e2e test takes a while --- test/dnup.Tests/xunit.runner.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 test/dnup.Tests/xunit.runner.json diff --git a/test/dnup.Tests/xunit.runner.json b/test/dnup.Tests/xunit.runner.json new file mode 100644 index 000000000000..6ebe966d8770 --- /dev/null +++ b/test/dnup.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "longRunningTestSeconds": 300 +} From 1c0e1118244fa3907f35d33e4bc3b43f4c4c56c9 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:21:12 -0700 Subject: [PATCH 107/153] use custommanifest path in IRA check --- src/Installer/dnup/InstallerOrchestratorSingleton.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 8d1afb782f9b..70d14149bb0d 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -57,7 +57,7 @@ private InstallerOrchestratorSingleton() // Extract and commit the install to the directory using (var finalizeLock = modifyInstallStateMutex()) { - if (InstallAlreadyExists(install)) + if (InstallAlreadyExists(install, customManifestPath)) { return install; } From 2555dbda7d54041813e002bc68d66656eccb2197 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:24:39 -0700 Subject: [PATCH 108/153] increase mutex wait time before giving up tests total runtime is 200 s on my machine - let's see if this helps. this would ideally be configurable because most users probably won't run multiple installs, though also they may have a much slower machine where this would cause concurrent requests to fail. --- src/Installer/dnup/ScopedMutex.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dnup/ScopedMutex.cs b/src/Installer/dnup/ScopedMutex.cs index 64252e5d0d91..320412aa86b8 100644 --- a/src/Installer/dnup/ScopedMutex.cs +++ b/src/Installer/dnup/ScopedMutex.cs @@ -25,7 +25,7 @@ public ScopedMutex(string name) } _mutex = new Mutex(false, mutexName); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(120), false); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(300), false); if (_hasHandle) { _holdCount.Value = _holdCount.Value + 1; From 3ed53ed142679468edd318108fb72264ca93d5f8 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:43:16 -0700 Subject: [PATCH 109/153] use a separate process so mutexes aren't shared improperly --- test/dnup.Tests/DnupE2Etest.cs | 15 +++-- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index f78be03807a1..a473710edd8d 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -57,9 +57,9 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); - // Execute the command with explicit manifest path + // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - int exitCode = Parser.Parse(args).Invoke(); + (int exitCode, _) = DnupTestUtilities.RunDnupProcess(args); exitCode.Should().Be(0); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); @@ -70,6 +70,7 @@ public void Test(string channel) using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); + var manifest = new DnupSharedManifest(testEnv.ManifestPath); installs = manifest.GetInstalledVersions().ToList(); } @@ -117,9 +118,9 @@ public void TestReusesExistingInstall() using var testEnv = DnupTestUtilities.CreateTestEnvironment(); var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - // Execute dnup to install the SDK the first time with explicit manifest path + // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - int exitCode = Parser.Parse(args).Invoke(); + (int exitCode, _) = DnupTestUtilities.RunDnupProcess(args); exitCode.Should().Be(0); List firstDnupInstalls = new(); @@ -127,6 +128,7 @@ public void TestReusesExistingInstall() using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(); + var manifest = new DnupSharedManifest(testEnv.ManifestPath); firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } @@ -136,12 +138,9 @@ public void TestReusesExistingInstall() using var consoleOutput = new ConsoleOutputCapture(); Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - exitCode = Parser.Parse(args).Invoke(); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); exitCode.Should().Be(0); - // Get the captured output - string output = consoleOutput.GetOutput(); - // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", "dnup should detect that the SDK is already installed and skip the installation"); diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index a2258486a8c1..a394fd1d4802 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -64,6 +64,65 @@ public static string[] BuildArguments(string channel, string installPath, string return [.. args]; } + /// + /// Runs the dnup executable as a separate process + /// + /// Command line arguments for dnup + /// Whether to capture and return the output + /// A tuple with exit code and captured output (if requested) + public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false) + { + string dnupPath = Path.Combine( + AppContext.BaseDirectory, // Test assembly directory + "..", "..", "..", "..", "..", // Navigate up to artifacts directory + "artifacts", "bin", "dnup", "Debug", "net10.0", "dnup.dll"); + + // Ensure path is normalized and exists + dnupPath = Path.GetFullPath(dnupPath); + if (!File.Exists(dnupPath)) + { + throw new FileNotFoundException($"dnup executable not found at: {dnupPath}"); + } + + using var process = new Process(); + process.StartInfo.FileName = "dotnet"; + process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.RedirectStandardOutput = captureOutput; + process.StartInfo.RedirectStandardError = captureOutput; + + StringBuilder outputBuilder = new(); + if (captureOutput) + { + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + } + }; + } + + process.Start(); + + if (captureOutput) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + process.WaitForExit(); + return (process.ExitCode, outputBuilder.ToString()); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From 5e5288d11bacced0c57f7a54cc3e2a4d78db30e1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:45:42 -0700 Subject: [PATCH 110/153] remove console output capture - extraneous now --- test/dnup.Tests/DnupE2Etest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index a473710edd8d..787658be52b3 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -135,8 +135,6 @@ public void TestReusesExistingInstall() firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); // Now install the same SDK again and capture the console output - using var consoleOutput = new ConsoleOutputCapture(); - Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); exitCode.Should().Be(0); From 42c9a4b98bd7d31229af64a74ff726c15df27be7 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Oct 2025 19:47:35 -0400 Subject: [PATCH 111/153] Add library API to get latest release in a channel --- .../IDotnetReleaseInfoProvider.cs | 2 +- .../InstallerFactory.cs | 5 +++ .../InstallerUtilities.cs | 27 ++++++++++++ .../Internal/DnupUtilities.cs | 16 ------- .../Internal/DotnetReleaseInfoProvider.cs | 22 ++++++++++ .../EnvironmentVariableMockDotnetInstaller.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 4 +- src/Installer/dnup/DotnetInstallManager.cs | 2 +- src/Installer/dnup/IDotnetInstallManager.cs | 7 +++ test/dnup.Tests/DnupE2Etest.cs | 2 +- test/dnup.Tests/LibraryTests.cs | 43 +++++++++++++++++++ test/dnup.Tests/dnup.Tests.csproj | 1 - 12 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs create mode 100644 src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs create mode 100644 test/dnup.Tests/LibraryTests.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs index 3f5e2e4d95a3..92571c2e4c8f 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs @@ -12,7 +12,7 @@ public interface IDotnetReleaseInfoProvider { IEnumerable GetAvailableChannels(); - ReleaseVersion GetLatestVersion(InstallComponent component, string channel); + ReleaseVersion? GetLatestVersion(InstallComponent component, string channel); // Get all versions in a channel - do we have a scenario for this? //IEnumerable GetAllVersions(InstallComponent component, string channel); diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs index 3b6e3559d5a9..a27a5c2ecdaf 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs @@ -14,4 +14,9 @@ public static IDotnetInstaller CreateInstaller() { return new DotnetInstaller(); } + + public static IDotnetReleaseInfoProvider CreateReleaseInfoProvider() + { + return new DotnetReleaseInfoProvider(); + } } diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs new file mode 100644 index 000000000000..3d01ed7960c6 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +public static class InstallerUtilities +{ + static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) + { + return architecture switch + { + System.Runtime.InteropServices.Architecture.X86 => InstallArchitecture.x86, + System.Runtime.InteropServices.Architecture.X64 => InstallArchitecture.x64, + System.Runtime.InteropServices.Architecture.Arm64 => InstallArchitecture.arm64, + _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") + }; + } + + public static InstallArchitecture GetDefaultInstallArchitecture() + { + return GetInstallArchitecture(RuntimeInformation.ProcessArchitecture); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs index d8b3f5f7d10d..b36f2be9e1e6 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs @@ -34,22 +34,6 @@ public static bool PathsEqual(string? a, string? b) StringComparison.OrdinalIgnoreCase); } - public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) - { - return architecture switch - { - System.Runtime.InteropServices.Architecture.X86 => InstallArchitecture.x86, - System.Runtime.InteropServices.Architecture.X64 => InstallArchitecture.x64, - System.Runtime.InteropServices.Architecture.Arm64 => InstallArchitecture.arm64, - _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") - }; - } - - public static InstallArchitecture GetDefaultInstallArchitecture() - { - return GetInstallArchitecture(RuntimeInformation.ProcessArchitecture); - } - public static void ForceReplaceFile(string sourcePath, string destPath) { File.Copy(sourcePath, destPath, overwrite: true); diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs new file mode 100644 index 000000000000..168bcb95f063 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal class DotnetReleaseInfoProvider : IDotnetReleaseInfoProvider +{ + public IEnumerable GetAvailableChannels() => throw new NotImplementedException(); + public ReleaseVersion? GetLatestVersion(InstallComponent component, string channel) + { + var releaseManifest = new ReleaseManifest(); + var release = releaseManifest.GetLatestVersionForChannel(new UpdateChannel(channel), component); + + return release; + } + public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs index ffa19fa6b8de..37fc7435c741 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -37,7 +37,7 @@ public string GetDefaultDotnetInstallPath() return null; } var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH") ?? GetDefaultDotnetInstallPath(); - return new(new(installPath, DnupUtilities.GetDefaultInstallArchitecture()), installtype, true, true); + return new(new(installPath, InstallerUtilities.GetDefaultInstallArchitecture()), installtype, true, true); } public string? GetLatestInstalledAdminVersion() diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 1c880fdf5556..102cb8786cc6 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -175,7 +175,7 @@ public override int Execute() // Create a request and resolve it using the channel version resolver var installRequest = new DotnetInstallRequest( - new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new DotnetInstallRoot(resolvedInstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(resolvedChannel), InstallComponent.SDK, new InstallRequestOptions()); @@ -226,7 +226,7 @@ public override int Execute() { // Create the request for the additional version var additionalRequest = new DotnetInstallRequest( - new DotnetInstallRoot(resolvedInstallPath, DnupUtilities.GetInstallArchitecture(RuntimeInformation.ProcessArchitecture)), + new DotnetInstallRoot(resolvedInstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(additionalVersion), InstallComponent.SDK, new InstallRequestOptions()); diff --git a/src/Installer/dnup/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs index e43cc5a46e98..bece54f44599 100644 --- a/src/Installer/dnup/DotnetInstallManager.cs +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -38,7 +38,7 @@ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); - var installRoot = new DotnetInstallRoot(installDir, DnupUtilities.GetDefaultInstallArchitecture()); + var installRoot = new DotnetInstallRoot(installDir, InstallerUtilities.GetDefaultInstallArchitecture()); bool isSetAsDotnetRoot = DnupUtilities.PathsEqual(dotnetRoot, installDir); diff --git a/src/Installer/dnup/IDotnetInstallManager.cs b/src/Installer/dnup/IDotnetInstallManager.cs index 1244ea90a3e1..9ae89598aa86 100644 --- a/src/Installer/dnup/IDotnetInstallManager.cs +++ b/src/Installer/dnup/IDotnetInstallManager.cs @@ -8,6 +8,13 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; +// Install process +// - Resolve version to install from channel +// - Handle writing to install manifest and garbage collection +// - Orchestrate installation so that only one install happens at a time +// - Call into installer implementation + + public interface IDotnetInstallManager { GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 40e914bcc5ef..eabe59f45b56 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -49,7 +49,7 @@ public void Test(string channel) var updateChannel = new UpdateChannel(channel); var expectedVersion = new ManifestChannelVersionResolver().Resolve( new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), updateChannel, InstallComponent.SDK, new InstallRequestOptions())); diff --git a/test/dnup.Tests/LibraryTests.cs b/test/dnup.Tests/LibraryTests.cs new file mode 100644 index 000000000000..0b3c68fc4b0f --- /dev/null +++ b/test/dnup.Tests/LibraryTests.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class LibraryTests +{ + ITestOutputHelper Log { get; } + + public LibraryTests(ITestOutputHelper log) + { + Log = log; + } + + [Theory] + [InlineData("9")] + [InlineData("latest")] + public void LatestVersionForChannelCanBeInstalled(string channel) + { + var releaseInfoProvider = InstallerFactory.CreateReleaseInfoProvider(); + + var latestVersion = releaseInfoProvider.GetLatestVersion(InstallComponent.SDK, channel); + Log.WriteLine($"Latest version for channel '{channel}' is '{latestVersion}'"); + + var installer = InstallerFactory.CreateInstaller(); + + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + Log.WriteLine($"Installing to path: {testEnv.InstallPath}"); + + installer.Install( + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + InstallComponent.SDK, + latestVersion!); + } +} diff --git a/test/dnup.Tests/dnup.Tests.csproj b/test/dnup.Tests/dnup.Tests.csproj index ca702c950709..d47cc410c996 100644 --- a/test/dnup.Tests/dnup.Tests.csproj +++ b/test/dnup.Tests/dnup.Tests.csproj @@ -4,7 +4,6 @@ enable $(ToolsetTargetFramework) Exe - Microsoft.DotNet.Tools.Bootstrapper true Tests\$(MSBuildProjectName) From ff435f8d596ac4e3ecb755eb24a3489288e92bd1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 16:58:18 -0700 Subject: [PATCH 112/153] Manifest paths should include custom path may refactor this later... --- .../dnup/InstallerOrchestratorSingleton.cs | 3 +- test/dnup.Tests/DnupE2Etest.cs | 18 +++++----- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 34 +++++++++++++++---- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs index 70d14149bb0d..d16d6fa69813 100644 --- a/src/Installer/dnup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Microsoft.Deployment.DotNet.Releases; @@ -67,7 +66,7 @@ private InstallerOrchestratorSingleton() ArchiveInstallationValidator validator = new(); if (validator.Validate(install)) { - DnupSharedManifest manifestManager = new(installRequest.Options.ManifestPath); + DnupSharedManifest manifestManager = new(customManifestPath); manifestManager.AddInstalledVersion(install); } else diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 787658be52b3..41592edf6ace 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -58,9 +58,9 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); // Execute the command with explicit manifest path as a separate process - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, _) = DnupTestUtilities.RunDnupProcess(args); - exitCode.Should().Be(0); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); @@ -69,7 +69,6 @@ public void Test(string channel) List installs = new(); using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { - var manifest = new DnupSharedManifest(); var manifest = new DnupSharedManifest(testEnv.ManifestPath); installs = manifest.GetInstalledVersions().ToList(); } @@ -120,14 +119,13 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, _) = DnupTestUtilities.RunDnupProcess(args); - exitCode.Should().Be(0); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); // Verify the installation was successful using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { - var manifest = new DnupSharedManifest(); var manifest = new DnupSharedManifest(testEnv.ManifestPath); firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } @@ -136,8 +134,8 @@ public void TestReusesExistingInstall() // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); - exitCode.Should().Be(0); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", @@ -151,7 +149,7 @@ public void TestReusesExistingInstall() // Verify the installation record in the manifest hasn't changed using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { - var manifest = new DnupSharedManifest(); + var manifest = new DnupSharedManifest(testEnv.ManifestPath); var installs = manifest.GetInstalledVersions(); matchingInstalls = installs.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); } diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index a394fd1d4802..55cca2bdee13 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -3,9 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Text; using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; @@ -72,11 +75,11 @@ public static string[] BuildArguments(string channel, string installPath, string /// A tuple with exit code and captured output (if requested) public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false) { + string repoRoot = GetRepositoryRoot(); string dnupPath = Path.Combine( - AppContext.BaseDirectory, // Test assembly directory - "..", "..", "..", "..", "..", // Navigate up to artifacts directory + repoRoot, "artifacts", "bin", "dnup", "Debug", "net10.0", "dnup.dll"); - + // Ensure path is normalized and exists dnupPath = Path.GetFullPath(dnupPath); if (!File.Exists(dnupPath)) @@ -84,8 +87,9 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c throw new FileNotFoundException($"dnup executable not found at: {dnupPath}"); } - using var process = new Process(); - process.StartInfo.FileName = "dotnet"; + using var process = new Process(); + string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); + process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; process.StartInfo.UseShellExecute = false; process.StartInfo.CreateNoWindow = true; @@ -95,14 +99,14 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c StringBuilder outputBuilder = new(); if (captureOutput) { - process.OutputDataReceived += (sender, e) => + process.OutputDataReceived += (sender, e) => { if (e.Data != null) { outputBuilder.AppendLine(e.Data); } }; - process.ErrorDataReceived += (sender, e) => + process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { @@ -123,6 +127,22 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c return (process.ExitCode, outputBuilder.ToString()); } + private static string GetRepositoryRoot() + { + var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); + while (currentDirectory != null) + { + if (File.Exists(Path.Combine(currentDirectory.FullName, "sdk.slnx"))) + { + return currentDirectory.FullName; + } + + currentDirectory = currentDirectory.Parent; + } + + throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From 1e6bb25b3e0fadf9f0b7b071cc77829ef035f10b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 17:14:42 -0700 Subject: [PATCH 113/153] Add CWD --- test/dnup.Tests/Utilities/DnupTestUtilities.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 55cca2bdee13..564e0e400b9c 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -73,7 +73,7 @@ public static string[] BuildArguments(string channel, string installPath, string /// Command line arguments for dnup /// Whether to capture and return the output /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false) + public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { string repoRoot = GetRepositoryRoot(); string dnupPath = Path.Combine( @@ -95,6 +95,7 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c process.StartInfo.CreateNoWindow = true; process.StartInfo.RedirectStandardOutput = captureOutput; process.StartInfo.RedirectStandardError = captureOutput; + process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; StringBuilder outputBuilder = new(); if (captureOutput) From b529951f4c763c537840d8f73156d2d1cd67a346 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 20 Oct 2025 17:15:00 -0700 Subject: [PATCH 114/153] Add cwd to tests to try to find the process --- test/dnup.Tests/DnupE2Etest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 41592edf6ace..16e7baadedc1 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -59,7 +59,7 @@ public void Test(string channel) // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); @@ -119,7 +119,7 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); @@ -134,7 +134,7 @@ public void TestReusesExistingInstall() // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed From dd5f687bbc9000a75b7f5a5b6d0b2dbb4e5dcf34 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 09:14:22 -0700 Subject: [PATCH 115/153] find executable more correctly we probably shouldnt hard code debug amongst other things. Im sure theres a better approach but just want to get it green first. --- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 564e0e400b9c..7595a7eacbdc 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -76,26 +76,17 @@ public static string[] BuildArguments(string channel, string installPath, string public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { string repoRoot = GetRepositoryRoot(); - string dnupPath = Path.Combine( - repoRoot, - "artifacts", "bin", "dnup", "Debug", "net10.0", "dnup.dll"); + string dnupPath = LocateDnupAssembly(repoRoot); - // Ensure path is normalized and exists - dnupPath = Path.GetFullPath(dnupPath); - if (!File.Exists(dnupPath)) - { - throw new FileNotFoundException($"dnup executable not found at: {dnupPath}"); - } - - using var process = new Process(); - string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); - process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); + using var process = new Process(); + string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); + process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; process.StartInfo.UseShellExecute = false; process.StartInfo.CreateNoWindow = true; process.StartInfo.RedirectStandardOutput = captureOutput; process.StartInfo.RedirectStandardError = captureOutput; - process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; + process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; StringBuilder outputBuilder = new(); if (captureOutput) @@ -144,6 +135,39 @@ private static string GetRepositoryRoot() throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); } + private static string LocateDnupAssembly(string repoRoot) + { + string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); + if (!Directory.Exists(artifactsRoot)) + { + throw new FileNotFoundException($"dnup build output not found. Expected directory: {artifactsRoot}"); + } + + var testAssemblyDirectory = new DirectoryInfo(AppContext.BaseDirectory); + string? tfm = testAssemblyDirectory.Name; + string? configuration = testAssemblyDirectory.Parent?.Name; + + if (!string.IsNullOrEmpty(configuration) && !string.IsNullOrEmpty(tfm)) + { + string candidate = Path.Combine(artifactsRoot, configuration, tfm, "dnup.dll"); + if (File.Exists(candidate)) + { + return candidate; + } + } + + string? fallback = Directory.EnumerateFiles(artifactsRoot, "dnup.dll", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + if (fallback != null) + { + return fallback; + } + + throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From 0fc7172351862847ce4abbd9443eb47d1d2ed838 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 10:44:59 -0700 Subject: [PATCH 116/153] Add concurrency test + Installation Validation This implements validation of installs using hostfxr apis to ensure the install actually works and not just that the manifest is tracking the installs correctly in e2e tests. It also adds a test to show that we can do multiple installs in the same directory without failing. It also improves the existing test logic to not assume a hard-coded debug value for the dnup process. --- .../Telemetry/EnvironmentDetectionRule.cs | 8 +- src/Installer/dnup/ArchiveDotnetInstaller.cs | 4 +- .../dnup/ArchiveInstallationValidator.cs | 138 +++++++++++++++++- src/Installer/dnup/BootstrapperController.cs | 2 +- ...ironmentVariableMockReleaseInfoProvider.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- src/Installer/dnup/DnupSharedManifest.cs | 2 +- src/Installer/dnup/dnup.csproj | 1 + test/dnup.Tests/DnupE2Etest.cs | 113 ++++++++++++-- test/dnup.Tests/ReleaseManifestTests.cs | 4 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 33 ++++- 12 files changed, 283 insertions(+), 32 deletions(-) diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs index 5cd73f53abb8..5f1aab066131 100644 --- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables) public override bool IsMatch() { - return _variables.Any(variable => + return _variables.Any(variable => bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); } } @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables) /// The result value if the rule matches; otherwise, null. public T? GetResult() { - return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - ? _result + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result : null; } -} \ No newline at end of file +} diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index fa5d95de57e3..520a69ec6df1 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -115,14 +115,14 @@ public void Commit(IEnumerable existingSdkVersions) { // When no-progress is enabled, install without progress display Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); - + // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); if (extractResult != null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } - + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); } else diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 0a7fee13c49f..cab1d0cc2e34 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -1,16 +1,152 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.NativeWrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveInstallationValidator : IInstallationValidator { + private const string HostFxrRuntimeProperty = "HOSTFXR_PATH"; + private static readonly Dictionary RuntimeMonikerByComponent = new() + { + [InstallComponent.Runtime] = "Microsoft.NETCore.App", + [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App", + [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App" + }; + public bool Validate(DotnetInstall install) { - // TODO: Implement validation logic + string? installRoot = install.InstallRoot.Path; + if (string.IsNullOrEmpty(installRoot)) + { + return false; + } + + string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetMuxerPath)) + { + return false; + } + + string resolvedVersion = install.Version.ToString(); + if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component)) + { + return false; + } + + if (!ValidateWithHostFxr(installRoot, install.Version, install.Component)) + { + return false; + } + + // We should also validate whether the host is the maximum version or higher than all installed versions. + return true; } + + private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component) + { + if (component == InstallComponent.SDK) + { + string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion); + return Directory.Exists(sdkDirectory); + } + + if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion); + return Directory.Exists(runtimeDirectory); + } + + return false; + } + + private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component) + { + try + { + ConfigureHostFxrResolution(installRoot); + + var bundleProvider = new NETBundlesNativeWrapper(); + NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot); + + if (component == InstallComponent.SDK) + { + string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString()); + return environmentInfo.SdkInfo.Any(sdk => + string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(sdk.Path, expectedPath)); + } + + if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + return false; + } + + string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString()); + return environmentInfo.RuntimeInfo.Any(runtime => + string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) && + string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath)); + } + catch + { + return false; + } + } + + private static void ConfigureHostFxrResolution(string installRoot) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + if (AppContext.GetData(HostFxrRuntimeProperty) != null) + { + return; + } + + string? hostFxrPath = FindHostFxrLibrary(installRoot); + if (hostFxrPath != null) + { + AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); + } + } + + private static string? FindHostFxrLibrary(string installRoot) + { + string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr"); + if (!Directory.Exists(hostFxrDirectory)) + { + return null; + } + + string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "hostfxr.dll" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "libhostfxr.dylib" + : "libhostfxr.so"; + + return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + private static void TraceValidation(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) + { + Console.WriteLine($"[ArchiveInstallationValidator] {message}"); + } + } } diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index ffc4ddc7f030..2fac49f3173d 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -29,7 +29,7 @@ public BootstrapperController(IEnvironmentProvider? environmentProvider = null) } string installDir = Path.GetDirectoryName(foundDotnet)!; - + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs index 29c71f70f56e..78a2b9a819dd 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -56,6 +56,6 @@ public ReleaseVersion GetLatestVersion(InstallComponent component, string channe } public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); - + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index af0bf71adcf2..3062307dca97 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -3,13 +3,11 @@ using System.CommandLine; using System.Net.Http; +using System.Runtime.InteropServices; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Spectre.Console; - - using SpectreAnsiConsole = Spectre.Console.AnsiConsole; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; -using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 13e5a6ff3bff..0c04bbeb6b2f 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -1,8 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json.Serialization; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 1e31330ca063..00dc4910a25b 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.IO; +using System.Linq; using System.Text.Json; using System.Threading; diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index f17eff4311d0..e849682cd060 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -40,6 +40,7 @@ + diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 16e7baadedc1..616a339f992b 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -5,11 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using FluentAssertions; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; -using Microsoft.Dotnet.Installation; using Xunit; namespace Microsoft.DotNet.Tools.Dnup.Tests; @@ -58,15 +59,15 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); // Execute the command with explicit manifest path as a separate process - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); // Verify the installation was properly recorded in the manifest - List installs = new(); + List installs = []; using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); @@ -79,7 +80,10 @@ public void Test(string channel) matchingInstalls.Should().ContainSingle(); var install = matchingInstalls[0]; - install.Component.Should().Be(Microsoft.Dotnet.Installation.InstallComponent.SDK); + install.Component.Should().Be(InstallComponent.SDK); + + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for installed version {install.Version} at {testEnv.InstallPath}"); // Verify the installed version matches what the resolver predicted if (!updateChannel.IsFullySpecifiedVersion()) @@ -119,8 +123,8 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -130,12 +134,14 @@ public void TestReusesExistingInstall() firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } - firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + var firstInstallRecord = firstDnupInstalls.Single(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)); + DnupTestUtilities.ValidateInstall(firstInstallRecord).Should().BeTrue( + $"ArchiveInstallationValidator failed for initial install of {channel} at {testEnv.InstallPath}"); // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", @@ -158,5 +164,90 @@ public void TestReusesExistingInstall() matchingInstalls.Should().ContainSingle(); // And it should be for the specified version matchingInstalls[0].Version.ToString().Should().Be(channel); + DnupTestUtilities.ValidateInstall(matchingInstalls[0]).Should().BeTrue( + $"ArchiveInstallationValidator failed after reinstall attempt for {channel} at {testEnv.InstallPath}"); + } +} + +/// +/// Tests that cover concurrent installs targeting the same install root and manifest. +/// +[Collection("DnupConcurrencyCollection")] +public class ConcurrentInstallationTests +{ + public static IEnumerable ConcurrentInstallChannels => new List + { + new object[] { "9.0.103", "9.0.103", false }, + new object[] { "9.0.103", "preview", true } + }; + + [Theory] + [MemberData(nameof(ConcurrentInstallChannels))] + public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, string secondChannel, bool expectDistinct) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + var resolver = new ManifestChannelVersionResolver(); + ReleaseVersion? firstResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(firstChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + ReleaseVersion? secondResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(secondChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + + firstResolved.Should().NotBeNull($"Channel {firstChannel} should resolve to a version"); + secondResolved.Should().NotBeNull($"Channel {secondChannel} should resolve to a version"); + + if (expectDistinct && string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Skipping concurrent distinct-version scenario because both channels resolved to {firstResolved}"); + return; + } + var args1 = DnupTestUtilities.BuildArguments(firstChannel, testEnv.InstallPath, testEnv.ManifestPath); + var args2 = DnupTestUtilities.BuildArguments(secondChannel, testEnv.InstallPath, testEnv.ManifestPath); + + var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); + var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); + + var results = await Task.WhenAll(installTask1, installTask2); + + results[0].exitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); + results[1].exitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); + + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + var installs = manifest.GetInstalledVersions() + .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) + .ToList(); + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); + + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); + } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); + } } } diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 5bc665138b18..dd957833046a 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -1,7 +1,7 @@ using System; -using Xunit; -using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.Tools.Bootstrapper; +using Xunit; namespace Microsoft.DotNet.Tools.Dnup.Tests { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 7595a7eacbdc..d4d7902f1f03 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using Microsoft.Dotnet.Installation; @@ -135,6 +136,12 @@ private static string GetRepositoryRoot() throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); } + public static bool ValidateInstall(DotnetInstall install) + { + var validator = new ArchiveInstallationValidator(); + return validator.Validate(install); + } + private static string LocateDnupAssembly(string repoRoot) { string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); @@ -147,12 +154,16 @@ private static string LocateDnupAssembly(string repoRoot) string? tfm = testAssemblyDirectory.Name; string? configuration = testAssemblyDirectory.Parent?.Name; - if (!string.IsNullOrEmpty(configuration) && !string.IsNullOrEmpty(tfm)) + if (!string.IsNullOrEmpty(tfm)) { - string candidate = Path.Combine(artifactsRoot, configuration, tfm, "dnup.dll"); - if (File.Exists(candidate)) + IEnumerable configurationCandidates = BuildConfigurationCandidates(configuration); + foreach (string candidateConfig in configurationCandidates) { - return candidate; + string candidate = Path.Combine(artifactsRoot, candidateConfig, tfm, "dnup.dll"); + if (File.Exists(candidate)) + { + return candidate; + } } } @@ -168,6 +179,20 @@ private static string LocateDnupAssembly(string repoRoot) throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); } + private static IEnumerable BuildConfigurationCandidates(string? configuration) + { + var candidates = new List(); + if (!string.IsNullOrEmpty(configuration)) + { + candidates.Add(configuration); + } + + candidates.Add("Debug"); + candidates.Add("Release"); + + return candidates.Distinct(StringComparer.OrdinalIgnoreCase); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From 30eedda7ce10777a6fc79901cacf2d838c6f5301 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 10:59:45 -0700 Subject: [PATCH 117/153] Revert "Add concurrency test + Installation Validation" This reverts commit 0fc7172351862847ce4abbd9443eb47d1d2ed838. --- .../Telemetry/EnvironmentDetectionRule.cs | 8 +- src/Installer/dnup/ArchiveDotnetInstaller.cs | 4 +- .../dnup/ArchiveInstallationValidator.cs | 138 +----------------- src/Installer/dnup/BootstrapperController.cs | 2 +- ...ironmentVariableMockReleaseInfoProvider.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 6 +- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- src/Installer/dnup/DnupSharedManifest.cs | 2 +- src/Installer/dnup/dnup.csproj | 1 - test/dnup.Tests/DnupE2Etest.cs | 113 ++------------ test/dnup.Tests/ReleaseManifestTests.cs | 4 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 33 +---- 12 files changed, 32 insertions(+), 283 deletions(-) diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs index 5f1aab066131..5cd73f53abb8 100644 --- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables) public override bool IsMatch() { - return _variables.Any(variable => + return _variables.Any(variable => bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); } } @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables) /// The result value if the rule matches; otherwise, null. public T? GetResult() { - return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - ? _result + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result : null; } -} +} \ No newline at end of file diff --git a/src/Installer/dnup/ArchiveDotnetInstaller.cs b/src/Installer/dnup/ArchiveDotnetInstaller.cs index 520a69ec6df1..fa5d95de57e3 100644 --- a/src/Installer/dnup/ArchiveDotnetInstaller.cs +++ b/src/Installer/dnup/ArchiveDotnetInstaller.cs @@ -115,14 +115,14 @@ public void Commit(IEnumerable existingSdkVersions) { // When no-progress is enabled, install without progress display Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); - + // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); if (extractResult != null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } - + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); } else diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index cab1d0cc2e34..0a7fee13c49f 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -1,152 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; -using System.Runtime.InteropServices; -using Microsoft.Deployment.DotNet.Releases; -using Microsoft.Dotnet.Installation; -using Microsoft.DotNet.NativeWrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveInstallationValidator : IInstallationValidator { - private const string HostFxrRuntimeProperty = "HOSTFXR_PATH"; - private static readonly Dictionary RuntimeMonikerByComponent = new() - { - [InstallComponent.Runtime] = "Microsoft.NETCore.App", - [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App", - [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App" - }; - public bool Validate(DotnetInstall install) { - string? installRoot = install.InstallRoot.Path; - if (string.IsNullOrEmpty(installRoot)) - { - return false; - } - - string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName()); - if (!File.Exists(dotnetMuxerPath)) - { - return false; - } - - string resolvedVersion = install.Version.ToString(); - if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component)) - { - return false; - } - - if (!ValidateWithHostFxr(installRoot, install.Version, install.Component)) - { - return false; - } - - // We should also validate whether the host is the maximum version or higher than all installed versions. - + // TODO: Implement validation logic return true; } - - private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component) - { - if (component == InstallComponent.SDK) - { - string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion); - return Directory.Exists(sdkDirectory); - } - - if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) - { - string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion); - return Directory.Exists(runtimeDirectory); - } - - return false; - } - - private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component) - { - try - { - ConfigureHostFxrResolution(installRoot); - - var bundleProvider = new NETBundlesNativeWrapper(); - NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot); - - if (component == InstallComponent.SDK) - { - string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString()); - return environmentInfo.SdkInfo.Any(sdk => - string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && - DnupUtilities.PathsEqual(sdk.Path, expectedPath)); - } - - if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) - { - return false; - } - - string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString()); - return environmentInfo.RuntimeInfo.Any(runtime => - string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) && - string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && - DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath)); - } - catch - { - return false; - } - } - - private static void ConfigureHostFxrResolution(string installRoot) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } - - if (AppContext.GetData(HostFxrRuntimeProperty) != null) - { - return; - } - - string? hostFxrPath = FindHostFxrLibrary(installRoot); - if (hostFxrPath != null) - { - AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); - } - } - - private static string? FindHostFxrLibrary(string installRoot) - { - string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr"); - if (!Directory.Exists(hostFxrDirectory)) - { - return null; - } - - string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "hostfxr.dll" - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? "libhostfxr.dylib" - : "libhostfxr.so"; - - return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories) - .OrderByDescending(File.GetLastWriteTimeUtc) - .FirstOrDefault(); - } - - private static void TraceValidation(string message) - { - if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) - { - Console.WriteLine($"[ArchiveInstallationValidator] {message}"); - } - } } diff --git a/src/Installer/dnup/BootstrapperController.cs b/src/Installer/dnup/BootstrapperController.cs index 2fac49f3173d..ffc4ddc7f030 100644 --- a/src/Installer/dnup/BootstrapperController.cs +++ b/src/Installer/dnup/BootstrapperController.cs @@ -29,7 +29,7 @@ public BootstrapperController(IEnvironmentProvider? environmentProvider = null) } string installDir = Path.GetDirectoryName(foundDotnet)!; - + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs index 78a2b9a819dd..29c71f70f56e 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -56,6 +56,6 @@ public ReleaseVersion GetLatestVersion(InstallComponent component, string channe } public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); - + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 3062307dca97..af0bf71adcf2 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -3,11 +3,13 @@ using System.CommandLine; using System.Net.Http; -using System.Runtime.InteropServices; using Microsoft.Deployment.DotNet.Releases; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Spectre.Console; + + using SpectreAnsiConsole = Spectre.Console.AnsiConsole; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using System.Runtime.InteropServices; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 0c04bbeb6b2f..13e5a6ff3bff 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -1,8 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; using System.Text.Json.Serialization; +using System.Collections.Generic; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 00dc4910a25b..1e31330ca063 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; +using System.IO; using System.Text.Json; using System.Threading; diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index e849682cd060..f17eff4311d0 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -40,7 +40,6 @@ - diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 616a339f992b..16e7baadedc1 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -5,12 +5,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Threading.Tasks; using FluentAssertions; using Microsoft.Deployment.DotNet.Releases; -using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; +using Microsoft.Dotnet.Installation; using Xunit; namespace Microsoft.DotNet.Tools.Dnup.Tests; @@ -59,15 +58,15 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); // Execute the command with explicit manifest path as a separate process - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); // Verify the installation was properly recorded in the manifest - List installs = []; + List installs = new(); using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); @@ -80,10 +79,7 @@ public void Test(string channel) matchingInstalls.Should().ContainSingle(); var install = matchingInstalls[0]; - install.Component.Should().Be(InstallComponent.SDK); - - DnupTestUtilities.ValidateInstall(install).Should().BeTrue( - $"ArchiveInstallationValidator failed for installed version {install.Version} at {testEnv.InstallPath}"); + install.Component.Should().Be(Microsoft.Dotnet.Installation.InstallComponent.SDK); // Verify the installed version matches what the resolver predicted if (!updateChannel.IsFullySpecifiedVersion()) @@ -123,8 +119,8 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -134,14 +130,12 @@ public void TestReusesExistingInstall() firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } - var firstInstallRecord = firstDnupInstalls.Single(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)); - DnupTestUtilities.ValidateInstall(firstInstallRecord).Should().BeTrue( - $"ArchiveInstallationValidator failed for initial install of {channel} at {testEnv.InstallPath}"); + firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", @@ -164,90 +158,5 @@ public void TestReusesExistingInstall() matchingInstalls.Should().ContainSingle(); // And it should be for the specified version matchingInstalls[0].Version.ToString().Should().Be(channel); - DnupTestUtilities.ValidateInstall(matchingInstalls[0]).Should().BeTrue( - $"ArchiveInstallationValidator failed after reinstall attempt for {channel} at {testEnv.InstallPath}"); - } -} - -/// -/// Tests that cover concurrent installs targeting the same install root and manifest. -/// -[Collection("DnupConcurrencyCollection")] -public class ConcurrentInstallationTests -{ - public static IEnumerable ConcurrentInstallChannels => new List - { - new object[] { "9.0.103", "9.0.103", false }, - new object[] { "9.0.103", "preview", true } - }; - - [Theory] - [MemberData(nameof(ConcurrentInstallChannels))] - public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, string secondChannel, bool expectDistinct) - { - using var testEnv = DnupTestUtilities.CreateTestEnvironment(); - - var resolver = new ManifestChannelVersionResolver(); - ReleaseVersion? firstResolved = resolver.Resolve( - new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), - new UpdateChannel(firstChannel), - InstallComponent.SDK, - new InstallRequestOptions())); - ReleaseVersion? secondResolved = resolver.Resolve( - new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), - new UpdateChannel(secondChannel), - InstallComponent.SDK, - new InstallRequestOptions())); - - firstResolved.Should().NotBeNull($"Channel {firstChannel} should resolve to a version"); - secondResolved.Should().NotBeNull($"Channel {secondChannel} should resolve to a version"); - - if (expectDistinct && string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase)) - { - Console.WriteLine($"Skipping concurrent distinct-version scenario because both channels resolved to {firstResolved}"); - return; - } - var args1 = DnupTestUtilities.BuildArguments(firstChannel, testEnv.InstallPath, testEnv.ManifestPath); - var args2 = DnupTestUtilities.BuildArguments(secondChannel, testEnv.InstallPath, testEnv.ManifestPath); - - var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); - var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); - - var results = await Task.WhenAll(installTask1, installTask2); - - results[0].exitCode.Should().Be(0, - $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); - results[1].exitCode.Should().Be(0, - $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); - - using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) - { - var manifest = new DnupSharedManifest(testEnv.ManifestPath); - var installs = manifest.GetInstalledVersions() - .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) - .ToList(); - - int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; - installs.Should().HaveCount(expectedInstallCount); - - var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - firstResolved.ToString()!, - secondResolved!.ToString()! - }; - - foreach (var install in installs) - { - install.Component.Should().Be(InstallComponent.SDK); - expectedVersions.Should().Contain(install.Version.ToString()); - DnupTestUtilities.ValidateInstall(install).Should().BeTrue( - $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); - } - - var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); - actualVersions.Should().BeEquivalentTo(expectedVersions); - } } } diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index dd957833046a..5bc665138b18 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -1,7 +1,7 @@ using System; -using Microsoft.Dotnet.Installation; -using Microsoft.DotNet.Tools.Bootstrapper; using Xunit; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Dnup.Tests { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index d4d7902f1f03..7595a7eacbdc 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using System.Runtime.InteropServices; using System.Text; using Microsoft.Dotnet.Installation; @@ -136,12 +135,6 @@ private static string GetRepositoryRoot() throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); } - public static bool ValidateInstall(DotnetInstall install) - { - var validator = new ArchiveInstallationValidator(); - return validator.Validate(install); - } - private static string LocateDnupAssembly(string repoRoot) { string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); @@ -154,16 +147,12 @@ private static string LocateDnupAssembly(string repoRoot) string? tfm = testAssemblyDirectory.Name; string? configuration = testAssemblyDirectory.Parent?.Name; - if (!string.IsNullOrEmpty(tfm)) + if (!string.IsNullOrEmpty(configuration) && !string.IsNullOrEmpty(tfm)) { - IEnumerable configurationCandidates = BuildConfigurationCandidates(configuration); - foreach (string candidateConfig in configurationCandidates) + string candidate = Path.Combine(artifactsRoot, configuration, tfm, "dnup.dll"); + if (File.Exists(candidate)) { - string candidate = Path.Combine(artifactsRoot, candidateConfig, tfm, "dnup.dll"); - if (File.Exists(candidate)) - { - return candidate; - } + return candidate; } } @@ -179,20 +168,6 @@ private static string LocateDnupAssembly(string repoRoot) throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); } - private static IEnumerable BuildConfigurationCandidates(string? configuration) - { - var candidates = new List(); - if (!string.IsNullOrEmpty(configuration)) - { - candidates.Add(configuration); - } - - candidates.Add("Debug"); - candidates.Add("Release"); - - return candidates.Distinct(StringComparer.OrdinalIgnoreCase); - } - /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From 9bd5bb05cec23ecbc988d38f3306c3b432b34586 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 11:47:09 -0700 Subject: [PATCH 118/153] fix strong name issue --- .../Microsoft.Dotnet.Installation.csproj | 5 ++++- src/Installer/dnup/dnup.csproj | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj index b2c706ac179e..a5b18eda75df 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -5,6 +5,9 @@ enable enable true + + + $(NoWarn);CS8002 @@ -12,5 +15,5 @@ - + diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index f17eff4311d0..b0a3cbc30fb3 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -7,7 +7,7 @@ enable true - + $(NoWarn);CS8002 From 171854b38fbd35c66f2a577259f334f13571a71b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 12:06:59 -0700 Subject: [PATCH 119/153] Allow public use of GetInstallArchitecture we can chat if we want this to be internal - though it might be helpful for consumers to be able to marshal the architecture from the runtime into our architecture type that we use in our install objects, considering it's part of dotnet install root, which is also public --- .../Microsoft.Dotnet.Installation/InstallerUtilities.cs | 3 ++- test/dnup.Tests/Utilities/DnupTestUtilities.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs index 3d01ed7960c6..4d061d72c318 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; +using System.Runtime.InteropServices; using System.Text; namespace Microsoft.Dotnet.Installation; public static class InstallerUtilities { - static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) { return architecture switch { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 7595a7eacbdc..0df8b5c6295f 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -5,9 +5,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Text; using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper; namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; @@ -172,5 +174,5 @@ private static string LocateDnupAssembly(string repoRoot) /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// public static InstallArchitecture MapArchitecture(Architecture architecture) => - Microsoft.DotNet.Tools.Bootstrapper.DnupUtilities.GetInstallArchitecture(architecture); + InstallerUtilities.GetInstallArchitecture(architecture); } From de951fdd171ae8d44db25144b5dcf083c7edfb19 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 10:44:59 -0700 Subject: [PATCH 120/153] Add concurrency test + Installation Validation This implements validation of installs using hostfxr apis to ensure the install actually works and not just that the manifest is tracking the installs correctly in e2e tests. It also adds a test to show that we can do multiple installs in the same directory without failing. It also improves the existing test logic to not assume a hard-coded debug value for the dnup process. --- .../Telemetry/EnvironmentDetectionRule.cs | 8 +- .../Internal/ArchiveDotnetExtractor.cs | 4 +- .../dnup/ArchiveInstallationValidator.cs | 138 +++++++++++++++++- ...ironmentVariableMockReleaseInfoProvider.cs | 2 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 8 +- src/Installer/dnup/DnupManifestJsonContext.cs | 2 +- src/Installer/dnup/DnupSharedManifest.cs | 2 +- src/Installer/dnup/DotnetInstallManager.cs | 2 +- src/Installer/dnup/dnup.csproj | 1 + test/dnup.Tests/DnupE2Etest.cs | 111 ++++++++++++-- test/dnup.Tests/ReleaseManifestTests.cs | 3 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 32 +++- 12 files changed, 281 insertions(+), 32 deletions(-) diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs index 5cd73f53abb8..5f1aab066131 100644 --- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables) public override bool IsMatch() { - return _variables.Any(variable => + return _variables.Any(variable => bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); } } @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables) /// The result value if the rule matches; otherwise, null. public T? GetResult() { - return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - ? _result + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result : null; } -} \ No newline at end of file +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs index 13ac693f2096..3c6513209d64 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -115,14 +115,14 @@ public void Commit(IEnumerable existingSdkVersions) { // When no-progress is enabled, install without progress display Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); - + // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); if (extractResult != null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } - + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); } else diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 8367577ed77e..ca3bb0084108 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -1,17 +1,153 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; using Microsoft.Dotnet.Installation.Internal; +using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.NativeWrapper; namespace Microsoft.DotNet.Tools.Bootstrapper; internal class ArchiveInstallationValidator : IInstallationValidator { + private const string HostFxrRuntimeProperty = "HOSTFXR_PATH"; + private static readonly Dictionary RuntimeMonikerByComponent = new() + { + [InstallComponent.Runtime] = "Microsoft.NETCore.App", + [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App", + [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App" + }; + public bool Validate(DotnetInstall install) { - // TODO: Implement validation logic + string? installRoot = install.InstallRoot.Path; + if (string.IsNullOrEmpty(installRoot)) + { + return false; + } + + string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetMuxerPath)) + { + return false; + } + + string resolvedVersion = install.Version.ToString(); + if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component)) + { + return false; + } + + if (!ValidateWithHostFxr(installRoot, install.Version, install.Component)) + { + return false; + } + + // We should also validate whether the host is the maximum version or higher than all installed versions. + return true; } + + private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component) + { + if (component == InstallComponent.SDK) + { + string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion); + return Directory.Exists(sdkDirectory); + } + + if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion); + return Directory.Exists(runtimeDirectory); + } + + return false; + } + + private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component) + { + try + { + ConfigureHostFxrResolution(installRoot); + + var bundleProvider = new NETBundlesNativeWrapper(); + NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot); + + if (component == InstallComponent.SDK) + { + string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString()); + return environmentInfo.SdkInfo.Any(sdk => + string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(sdk.Path, expectedPath)); + } + + if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + return false; + } + + string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString()); + return environmentInfo.RuntimeInfo.Any(runtime => + string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) && + string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath)); + } + catch + { + return false; + } + } + + private static void ConfigureHostFxrResolution(string installRoot) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + if (AppContext.GetData(HostFxrRuntimeProperty) != null) + { + return; + } + + string? hostFxrPath = FindHostFxrLibrary(installRoot); + if (hostFxrPath != null) + { + AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); + } + } + + private static string? FindHostFxrLibrary(string installRoot) + { + string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr"); + if (!Directory.Exists(hostFxrDirectory)) + { + return null; + } + + string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "hostfxr.dll" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "libhostfxr.dylib" + : "libhostfxr.so"; + + return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + private static void TraceValidation(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) + { + Console.WriteLine($"[ArchiveInstallationValidator] {message}"); + } + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs index 29c71f70f56e..78a2b9a819dd 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -56,6 +56,6 @@ public ReleaseVersion GetLatestVersion(InstallComponent component, string channe } public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); - + } } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index 882488a1e359..c4481b4bc247 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -3,14 +3,12 @@ using System.CommandLine; using System.Net.Http; +using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Spectre.Console; - - using SpectreAnsiConsole = Spectre.Console.AnsiConsole; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; -using System.Runtime.InteropServices; -using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs index 40fd57b79d95..0c7809151c1f 100644 --- a/src/Installer/dnup/DnupManifestJsonContext.cs +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -1,9 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text.Json.Serialization; using System.Collections.Generic; using Microsoft.Dotnet.Installation.Internal; +using System.Text.Json.Serialization; namespace Microsoft.DotNet.Tools.Bootstrapper { diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index 0d221388b652..cb075a5e2807 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.IO; +using System.Linq; using System.Text.Json; using System.Threading; using Microsoft.Dotnet.Installation.Internal; diff --git a/src/Installer/dnup/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs index bece54f44599..67e1652f37b4 100644 --- a/src/Installer/dnup/DotnetInstallManager.cs +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -30,7 +30,7 @@ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) } string installDir = Path.GetDirectoryName(foundDotnet)!; - + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj index b0a3cbc30fb3..a272529118cb 100644 --- a/src/Installer/dnup/dnup.csproj +++ b/src/Installer/dnup/dnup.csproj @@ -40,6 +40,7 @@ + diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 7ddea9f7f669..d225ded86104 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -5,11 +5,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using FluentAssertions; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; -using Microsoft.Dotnet.Installation; using Xunit; using Microsoft.Dotnet.Installation.Internal; @@ -59,9 +60,9 @@ public void Test(string channel) Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); // Execute the command with explicit manifest path as a separate process - var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); @@ -80,7 +81,10 @@ public void Test(string channel) matchingInstalls.Should().ContainSingle(); var install = matchingInstalls[0]; - install.Component.Should().Be(Microsoft.Dotnet.Installation.InstallComponent.SDK); + install.Component.Should().Be(InstallComponent.SDK); + + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for installed version {install.Version} at {testEnv.InstallPath}"); // Verify the installed version matches what the resolver predicted if (!updateChannel.IsFullySpecifiedVersion()) @@ -120,8 +124,8 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -131,12 +135,14 @@ public void TestReusesExistingInstall() firstDnupInstalls = manifest.GetInstalledVersions().ToList(); } - firstDnupInstalls.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).Should().ContainSingle(); + var firstInstallRecord = firstDnupInstalls.Single(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)); + DnupTestUtilities.ValidateInstall(firstInstallRecord).Should().BeTrue( + $"ArchiveInstallationValidator failed for initial install of {channel} at {testEnv.InstallPath}"); // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); // Verify the output contains a message indicating the SDK is already installed output.Should().Contain("is already installed, skipping installation", @@ -159,5 +165,90 @@ public void TestReusesExistingInstall() matchingInstalls.Should().ContainSingle(); // And it should be for the specified version matchingInstalls[0].Version.ToString().Should().Be(channel); + DnupTestUtilities.ValidateInstall(matchingInstalls[0]).Should().BeTrue( + $"ArchiveInstallationValidator failed after reinstall attempt for {channel} at {testEnv.InstallPath}"); + } +} + +/// +/// Tests that cover concurrent installs targeting the same install root and manifest. +/// +[Collection("DnupConcurrencyCollection")] +public class ConcurrentInstallationTests +{ + public static IEnumerable ConcurrentInstallChannels => new List + { + new object[] { "9.0.103", "9.0.103", false }, + new object[] { "9.0.103", "preview", true } + }; + + [Theory] + [MemberData(nameof(ConcurrentInstallChannels))] + public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, string secondChannel, bool expectDistinct) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + var resolver = new ManifestChannelVersionResolver(); + ReleaseVersion? firstResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(firstChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + ReleaseVersion? secondResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(secondChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + + firstResolved.Should().NotBeNull($"Channel {firstChannel} should resolve to a version"); + secondResolved.Should().NotBeNull($"Channel {secondChannel} should resolve to a version"); + + if (expectDistinct && string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Skipping concurrent distinct-version scenario because both channels resolved to {firstResolved}"); + return; + } + var args1 = DnupTestUtilities.BuildArguments(firstChannel, testEnv.InstallPath, testEnv.ManifestPath); + var args2 = DnupTestUtilities.BuildArguments(secondChannel, testEnv.InstallPath, testEnv.ManifestPath); + + var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); + var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); + + var results = await Task.WhenAll(installTask1, installTask2); + + results[0].exitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); + results[1].exitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); + + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + var installs = manifest.GetInstalledVersions() + .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) + .ToList(); + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); + + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); + } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); + } } } diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs index 85e7cfcf8868..63dab25f79a3 100644 --- a/test/dnup.Tests/ReleaseManifestTests.cs +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -1,8 +1,7 @@ using System; -using Xunit; -using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; +using Xunit; namespace Microsoft.DotNet.Tools.Dnup.Tests { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 0df8b5c6295f..7f25c2393d6b 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -137,6 +137,12 @@ private static string GetRepositoryRoot() throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); } + public static bool ValidateInstall(DotnetInstall install) + { + var validator = new ArchiveInstallationValidator(); + return validator.Validate(install); + } + private static string LocateDnupAssembly(string repoRoot) { string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); @@ -149,12 +155,16 @@ private static string LocateDnupAssembly(string repoRoot) string? tfm = testAssemblyDirectory.Name; string? configuration = testAssemblyDirectory.Parent?.Name; - if (!string.IsNullOrEmpty(configuration) && !string.IsNullOrEmpty(tfm)) + if (!string.IsNullOrEmpty(tfm)) { - string candidate = Path.Combine(artifactsRoot, configuration, tfm, "dnup.dll"); - if (File.Exists(candidate)) + IEnumerable configurationCandidates = BuildConfigurationCandidates(configuration); + foreach (string candidateConfig in configurationCandidates) { - return candidate; + string candidate = Path.Combine(artifactsRoot, candidateConfig, tfm, "dnup.dll"); + if (File.Exists(candidate)) + { + return candidate; + } } } @@ -170,6 +180,20 @@ private static string LocateDnupAssembly(string repoRoot) throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); } + private static IEnumerable BuildConfigurationCandidates(string? configuration) + { + var candidates = new List(); + if (!string.IsNullOrEmpty(configuration)) + { + candidates.Add(configuration); + } + + candidates.Add("Debug"); + candidates.Add("Release"); + + return candidates.Distinct(StringComparer.OrdinalIgnoreCase); + } + /// /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture /// From ca115a42075c647bb082161e7d0358153071103c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 12:56:13 -0700 Subject: [PATCH 121/153] remove extra GCP fnct --- src/Installer/dnup/ArchiveInstallationValidator.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index ca3bb0084108..9a8af2a0733b 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -142,12 +142,4 @@ private static void ConfigureHostFxrResolution(string installRoot) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); } - - private static void TraceValidation(string message) - { - if (string.Equals(Environment.GetEnvironmentVariable("DNUP_VALIDATOR_TRACE"), "1", StringComparison.Ordinal)) - { - Console.WriteLine($"[ArchiveInstallationValidator] {message}"); - } - } } From 515508d131a0b8873db7942ae7af8a5d042e517a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 13:26:41 -0700 Subject: [PATCH 122/153] Fix new references --- test/dnup.Tests/DnupE2Etest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index d225ded86104..2ff41837d162 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -191,13 +191,13 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, var resolver = new ManifestChannelVersionResolver(); ReleaseVersion? firstResolved = resolver.Resolve( new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(firstChannel), InstallComponent.SDK, new InstallRequestOptions())); ReleaseVersion? secondResolved = resolver.Resolve( new DotnetInstallRequest( - new DotnetInstallRoot(testEnv.InstallPath, DnupUtilities.GetDefaultInstallArchitecture()), + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), new UpdateChannel(secondChannel), InstallComponent.SDK, new InstallRequestOptions())); From d5844ab01ffd6ffac2f86f570714b8d1c6c35d50 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 14:11:27 -0700 Subject: [PATCH 123/153] Enable debugging into dnup process in test --- src/Installer/dnup/DnupDebugHelper.cs | 35 +++++++++++++++++++ src/Installer/dnup/Program.cs | 4 +++ .../src/RulesMissingDocumentation.md | 4 --- test/dnup.Tests/DnupE2Etest.cs | 1 + .../dnup.Tests/Utilities/DnupTestUtilities.cs | 31 ++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/Installer/dnup/DnupDebugHelper.cs diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs new file mode 100644 index 000000000000..07d00f0d86e7 --- /dev/null +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +// Copy of DebugHelper.cs in the SDK - port eventually. +internal static class DnupDebugHelper +{ + [Conditional("DEBUG")] + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = args.Skip(1).ToArray(); + WaitForDebugger(); + } + } + + public static void WaitForDebugger() + { +#if NET5_0_OR_GREATER + int processId = Environment.ProcessId; +#else + int processId = Process.GetCurrentProcess().Id; +#endif + + Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); + Console.WriteLine($"Process ID: {processId}"); + Console.ReadLine(); + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs index ee656bfa6003..4969b276d7ef 100644 --- a/src/Installer/dnup/Program.cs +++ b/src/Installer/dnup/Program.cs @@ -7,6 +7,10 @@ internal class DnupProgram { public static int Main(string[] args) { + // Handle --debug flag using the standard .NET SDK pattern + // This is DEBUG-only and removes the --debug flag from args + DnupDebugHelper.HandleDebugSwitch(ref args); + var parseResult = Parser.Parse(args); return Parser.Invoke(parseResult); } diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md index c5820fde91ae..75e0f77588e2 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md @@ -2,7 +2,3 @@ Rule ID | Missing Help Link | Title | --------|-------------------|-------| -CA1873 | | Avoid potentially expensive logging | -CA1874 | | Use 'Regex.IsMatch' | -CA1875 | | Use 'Regex.Count' | -CA2023 | | Invalid braces in message template | diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 2ff41837d162..1fa6c46465bd 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -61,6 +61,7 @@ public void Test(string channel) // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 7f25c2393d6b..ed244672862d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -77,6 +77,14 @@ public static string[] BuildArguments(string channel, string installPath, string /// A tuple with exit code and captured output (if requested) public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { + // In DEBUG builds, automatically add --debug flag for easier debugging +#if DEBUG + if (!args.Contains("--debug")) + { + args = new[] { "--debug" }.Concat(args).ToArray(); + } +#endif + string repoRoot = GetRepositoryRoot(); string dnupPath = LocateDnupAssembly(repoRoot); @@ -121,6 +129,29 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c return (process.ExitCode, outputBuilder.ToString()); } + /// + /// Runs dnup process with debugging support - waits for debugger attachment + /// Note: This only works in DEBUG builds of dnup + /// + /// Command line arguments for dnup + /// Whether to capture and return the output + /// Working directory for the process + /// A tuple with exit code and captured output (if requested) + public static (int exitCode, string output) RunDnupProcessWithDebugger(string[] args, bool captureOutput = false, string? workingDirectory = null) + { + // Add --debug flag to enable debugger waiting (only works in DEBUG builds) + var debugArgs = new[] { "--debug" }.Concat(args).ToArray(); + + Console.WriteLine("Starting dnup process in debug mode..."); + Console.WriteLine("Note: --debug flag only works in DEBUG builds of dnup"); + Console.WriteLine("To attach debugger:"); + Console.WriteLine("1. In Visual Studio: Debug -> Attach to Process"); + Console.WriteLine("2. Find the dotnet.exe process running dnup"); + Console.WriteLine("3. Attach to it, then press Enter in the console"); + + return RunDnupProcess(debugArgs, captureOutput, workingDirectory); + } + private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); From ec64dc990b574e978596cf1abeac1726a4434977 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 14:18:04 -0700 Subject: [PATCH 124/153] Create a window if we are in debug mode so we can press enter. --- test/dnup.Tests/Utilities/DnupTestUtilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index ed244672862d..6b1a8cde375f 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -93,7 +93,7 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = true; + process.StartInfo.CreateNoWindow = !args.Contains("--debug"); process.StartInfo.RedirectStandardOutput = captureOutput; process.StartInfo.RedirectStandardError = captureOutput; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; From deed5e288fe1ccf651005b30ddcc6716358c7934 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 15:35:50 -0700 Subject: [PATCH 125/153] Can properly attach to test debug process though now it always expects an attacher --- test/dnup.Tests/DnupE2Etest.cs | 38 +++---- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 98 ++++++++++++------- 2 files changed, 86 insertions(+), 50 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 1fa6c46465bd..0de472f98eda 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -62,8 +62,9 @@ public void Test(string channel) // Execute the command with explicit manifest path as a separate process var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); - (int exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"dnup exited with code {exitCode}. Output:\n{output}"); + DnupProcessResult result = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + result.ExitCode.Should().Be(0, + $"dnup exited with code {result.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(result)}"); Directory.Exists(testEnv.InstallPath).Should().BeTrue(); Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); @@ -125,8 +126,9 @@ public void TestReusesExistingInstall() // Execute dnup to install the SDK the first time with explicit manifest path as a separate process Console.WriteLine($"First installation of {channel}"); - (int exitCode, string firstInstallOutput) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"First installation failed with exit code {exitCode}. Output:\n{firstInstallOutput}"); + DnupProcessResult firstInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + firstInstall.ExitCode.Should().Be(0, + $"First installation failed with exit code {firstInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(firstInstall)}"); List firstDnupInstalls = new(); // Verify the installation was successful @@ -142,16 +144,18 @@ public void TestReusesExistingInstall() // Now install the same SDK again and capture the console output Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); - (exitCode, string output) = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); - exitCode.Should().Be(0, $"Second installation failed with exit code {exitCode}. Output:\n{output}"); + DnupProcessResult secondInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + secondInstall.ExitCode.Should().Be(0, + $"Second installation failed with exit code {secondInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(secondInstall)}"); - // Verify the output contains a message indicating the SDK is already installed - output.Should().Contain("is already installed, skipping installation", - "dnup should detect that the SDK is already installed and skip the installation"); + DnupTestUtilities.AssertOutput(secondInstall, output => + { + output.Should().Contain("is already installed, skipping installation", + "dnup should detect that the SDK is already installed and skip the installation"); - // The output should not contain download progress - output.Should().NotContain("Downloading .NET SDK", - "dnup should not attempt to download the SDK again"); + output.Should().NotContain("Downloading .NET SDK", + "dnup should not attempt to download the SDK again"); + }); List matchingInstalls = new(); // Verify the installation record in the manifest hasn't changed @@ -217,12 +221,12 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); - var results = await Task.WhenAll(installTask1, installTask2); + DnupProcessResult[] results = await Task.WhenAll(installTask1, installTask2); - results[0].exitCode.Should().Be(0, - $"First concurrent install failed with exit code {results[0].exitCode}. Output:\n{results[0].output}"); - results[1].exitCode.Should().Be(0, - $"Second concurrent install failed with exit code {results[1].exitCode}. Output:\n{results[1].output}"); + results[0].ExitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[0])}"); + results[1].ExitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[1])}"); using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 6b1a8cde375f..31680cf2d51d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -50,8 +50,10 @@ public static string[] BuildArguments(string channel, string installPath, string args.Add("--install-path"); args.Add(installPath); - args.Add("--interactive"); - args.Add("false"); +#if !DEBUG + args.Add("--interactive"); + args.Add("false"); +#endif // Add manifest path option if specified for test isolation if (!string.IsNullOrEmpty(manifestPath)) @@ -70,12 +72,12 @@ public static string[] BuildArguments(string channel, string installPath, string } /// - /// Runs the dnup executable as a separate process + /// Runs the dnup executable as a separate process. /// - /// Command line arguments for dnup - /// Whether to capture and return the output - /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) + /// Command line arguments for dnup. + /// Whether to capture and return the output. + /// Process result including exit code and output (if captured). + public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { // In DEBUG builds, automatically add --debug flag for easier debugging #if DEBUG @@ -92,23 +94,29 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; - process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = !args.Contains("--debug"); - process.StartInfo.RedirectStandardOutput = captureOutput; - process.StartInfo.RedirectStandardError = captureOutput; + + bool isDebugMode = args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase)); + bool useShellExecute = isDebugMode && OperatingSystem.IsWindows(); + + process.StartInfo.UseShellExecute = useShellExecute; + process.StartInfo.CreateNoWindow = !useShellExecute; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; + bool shouldCaptureOutput = captureOutput && !useShellExecute; + StringBuilder outputBuilder = new(); - if (captureOutput) + if (shouldCaptureOutput) { - process.OutputDataReceived += (sender, e) => + process.StartInfo.RedirectStandardOutput = shouldCaptureOutput; + process.StartInfo.RedirectStandardError = shouldCaptureOutput; + process.OutputDataReceived += (_, e) => { if (e.Data != null) { outputBuilder.AppendLine(e.Data); } }; - process.ErrorDataReceived += (sender, e) => + process.ErrorDataReceived += (_, e) => { if (e.Data != null) { @@ -119,39 +127,47 @@ public static (int exitCode, string output) RunDnupProcess(string[] args, bool c process.Start(); - if (captureOutput) + if (shouldCaptureOutput) { process.BeginOutputReadLine(); process.BeginErrorReadLine(); } + if (isDebugMode) + { + Console.WriteLine($"Started dnup process with PID: {process.Id}"); + Console.WriteLine(useShellExecute + ? "Interactive console window launched for debugger attachment." + : "Process is sharing the current console for debugger attachment."); + Console.WriteLine("To attach debugger: Debug -> Attach to Process -> Select the dotnet.exe process"); + } + process.WaitForExit(); - return (process.ExitCode, outputBuilder.ToString()); + + string output = shouldCaptureOutput ? outputBuilder.ToString() : string.Empty; + return new DnupProcessResult(process.ExitCode, output, shouldCaptureOutput); } /// - /// Runs dnup process with debugging support - waits for debugger attachment - /// Note: This only works in DEBUG builds of dnup + /// Executes output assertions only when dnup output was captured. /// - /// Command line arguments for dnup - /// Whether to capture and return the output - /// Working directory for the process - /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDnupProcessWithDebugger(string[] args, bool captureOutput = false, string? workingDirectory = null) + public static void AssertOutput(DnupProcessResult result, Action assertion) { - // Add --debug flag to enable debugger waiting (only works in DEBUG builds) - var debugArgs = new[] { "--debug" }.Concat(args).ToArray(); - - Console.WriteLine("Starting dnup process in debug mode..."); - Console.WriteLine("Note: --debug flag only works in DEBUG builds of dnup"); - Console.WriteLine("To attach debugger:"); - Console.WriteLine("1. In Visual Studio: Debug -> Attach to Process"); - Console.WriteLine("2. Find the dotnet.exe process running dnup"); - Console.WriteLine("3. Attach to it, then press Enter in the console"); + if (!result.OutputCaptured) + { + Console.WriteLine("Skipping output assertions because dnup output was not captured (debug mode with ShellExecute)."); + return; + } - return RunDnupProcess(debugArgs, captureOutput, workingDirectory); + assertion(result.Output); } + /// + /// Formats dnup output for inclusion in assertion messages. + /// + public static string FormatOutputForAssertions(DnupProcessResult result) => + result.OutputCaptured ? result.Output : "[dnup output not captured; run without --debug to capture output]"; + private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); @@ -231,3 +247,19 @@ private static IEnumerable BuildConfigurationCandidates(string? configur public static InstallArchitecture MapArchitecture(Architecture architecture) => InstallerUtilities.GetInstallArchitecture(architecture); } + +internal readonly record struct DnupProcessResult(int ExitCode, string Output, bool OutputCaptured) +{ + public void Deconstruct(out int exitCode, out string output) + { + exitCode = ExitCode; + output = Output; + } + + public void Deconstruct(out int exitCode, out string output, out bool outputCaptured) + { + exitCode = ExitCode; + output = Output; + outputCaptured = OutputCaptured; + } +} From 5306a16e4cc49b4ff52ec81b3e44654f8d8355d2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 16:19:30 -0700 Subject: [PATCH 126/153] Switch to environment variable that enables debug launching into the test. --- src/Installer/dnup/DnupDebugHelper.cs | 2 +- src/Installer/installer.code-workspace | 1 + .../dnup.Tests/Utilities/DnupTestUtilities.cs | 60 ++++++++++++++----- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs index 07d00f0d86e7..4013838a227d 100644 --- a/src/Installer/dnup/DnupDebugHelper.cs +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -15,7 +15,7 @@ public static void HandleDebugSwitch(ref string[] args) { if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) { - args = args.Skip(1).ToArray(); + args = [.. args.Skip(1)]; WaitForDebugger(); } } diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 2d4e9f997dfb..57355ef96c7f 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -45,6 +45,7 @@ "request": "launch", "preLaunchTask": "build", "program": "dotnet", + "args": [ "args": [ "test", "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 31680cf2d51d..5c1dcb83c42d 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -45,15 +45,16 @@ public static string[] BuildArguments(string channel, string installPath, string { "sdk", "install", - channel + channel, + "--install-path", + installPath }; - args.Add("--install-path"); - args.Add(installPath); -#if !DEBUG + if (!ShouldForceDebug()) + { args.Add("--interactive"); args.Add("false"); -#endif + } // Add manifest path option if specified for test isolation if (!string.IsNullOrEmpty(manifestPath)) @@ -79,13 +80,10 @@ public static string[] BuildArguments(string channel, string installPath, string /// Process result including exit code and output (if captured). public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) { - // In DEBUG builds, automatically add --debug flag for easier debugging -#if DEBUG - if (!args.Contains("--debug")) + if (ShouldForceDebug()) { - args = new[] { "--debug" }.Concat(args).ToArray(); + args = EnsureDebugFlag(args); } -#endif string repoRoot = GetRepositoryRoot(); string dnupPath = LocateDnupAssembly(repoRoot); @@ -95,9 +93,7 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; - bool isDebugMode = args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase)); - bool useShellExecute = isDebugMode && OperatingSystem.IsWindows(); - + bool useShellExecute = ShouldForceDebug(); process.StartInfo.UseShellExecute = useShellExecute; process.StartInfo.CreateNoWindow = !useShellExecute; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; @@ -133,7 +129,7 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.BeginErrorReadLine(); } - if (isDebugMode) + if (ShouldForceDebug()) { Console.WriteLine($"Started dnup process with PID: {process.Id}"); Console.WriteLine(useShellExecute @@ -148,6 +144,31 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput return new DnupProcessResult(process.ExitCode, output, shouldCaptureOutput); } + private static string[] EnsureDebugFlag(string[] args) + { + if (args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase))) + { + return args; + } + + string[] updated = new string[args.Length + 1]; + updated[0] = "--debug"; + Array.Copy(args, 0, updated, 1, args.Length); + return updated; + } + + private static bool ShouldForceDebug() + { + string? value = Environment.GetEnvironmentVariable("DNUP_TEST_DEBUG"); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + /// /// Executes output assertions only when dnup output was captured. /// @@ -235,8 +256,15 @@ private static IEnumerable BuildConfigurationCandidates(string? configur candidates.Add(configuration); } - candidates.Add("Debug"); - candidates.Add("Release"); + if (!candidates.Contains("Release", StringComparer.OrdinalIgnoreCase)) + { + candidates.Insert(0, "Release"); + } + + if (!candidates.Contains("Debug", StringComparer.OrdinalIgnoreCase)) + { + candidates.Add("Debug"); + } return candidates.Distinct(StringComparer.OrdinalIgnoreCase); } From f919aa224746c4f1235d9968a8e73ed97d0173dc Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 21 Oct 2025 16:19:59 -0700 Subject: [PATCH 127/153] Workspace should enable debugging singular test --- src/Installer/installer.code-workspace | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 57355ef96c7f..b7da3fab1e10 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -46,10 +46,29 @@ "preLaunchTask": "build", "program": "dotnet", "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/p:ContinuousIntegrationBuild=false", + ], + "cwd": "${workspaceFolder:dnup.Tests}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DNUP_TEST_DEBUG": "0" + } + }, + { + "name": "Debug dnup test", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", "args": [ "test", "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", "--no-build", + "--filter", + "FullyQualifiedName~${input:testName}", "/p:ContinuousIntegrationBuild=false", "/p:UseSharedCompilation=true", "/p:Deterministic=false", @@ -58,10 +77,29 @@ ], "cwd": "${workspaceFolder:dnup.Tests}", "console": "integratedTerminal", - "stopAtEntry": false + "stopAtEntry": false, + "logging": { + "moduleLoad": false + }, + "env": { + "DNUP_TEST_DEBUG": "1" + } } ], - "compounds": [] + "compounds": [], + "inputs": [ + { + "id": "testName", + "type": "promptString", + "description": "Enter test name or partial test name to debug (e.g., 'InstallChannelVersionTest', 'Install', etc.)" + }, + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments for dnup (e.g., 'sdk install 9.0', 'runtime install lts', '--help')", + "default": "sdk install" + } + ] }, "tasks": { "version": "2.0.0", @@ -128,4 +166,4 @@ }, ] } -} \ No newline at end of file +} From ab7a47f25b0f9da7dc104d203083f01c2dfe2846 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:13:15 -0700 Subject: [PATCH 128/153] Fix Version Parsing issues in ReleaseManifest --- .../DownloadProgress.cs | 31 +++ .../Internal/ArchiveDotnetExtractor.cs | 10 +- .../Internal/ReleaseManifest.cs | 239 +++--------------- .../SpectreDownloadProgressReporter.cs | 1 + .../dnup/ArchiveInstallationValidator.cs | 4 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 16 +- src/Installer/dnup/DnupSharedManifest.cs | 14 +- src/Installer/dnup/IDotnetInstallManager.cs | 2 +- src/Installer/installer.code-workspace | 2 +- test/dnup.Tests/DnupE2Etest.cs | 2 +- .../dnup.Tests/Utilities/DnupTestUtilities.cs | 8 +- 11 files changed, 94 insertions(+), 235 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs new file mode 100644 index 000000000000..6afe6cc0756a --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs @@ -0,0 +1,31 @@ +using System; + +namespace Microsoft.Dotnet.Installation +{ + /// + /// Represents download progress information. + /// + public readonly struct DownloadProgress + { + /// + /// Gets the number of bytes downloaded. + /// + public long BytesDownloaded { get; } + + /// + /// Gets the total number of bytes to download, if known. + /// + public long? TotalBytes { get; } + + /// + /// Gets the percentage of download completed, if total size is known. + /// + public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; + + public DownloadProgress(long bytesDownloaded, long? totalBytes) + { + BytesDownloaded = bytesDownloaded; + TotalBytes = totalBytes; + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs index 3c6513209d64..83b29ea5d807 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -118,7 +118,7 @@ public void Commit(IEnumerable existingSdkVersions) // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); - if (extractResult != null) + if (extractResult is not null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } @@ -135,7 +135,7 @@ public void Commit(IEnumerable existingSdkVersions) // Extract archive directly to target directory with special handling for muxer var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); - if (extractResult != null) + if (extractResult is not null) { throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); } @@ -204,7 +204,7 @@ private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable e long totalFiles = CountTarEntries(decompressedPath); // Set progress maximum - if (installTask != null) + if (installTask is not null) { installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; } @@ -255,7 +255,7 @@ private long CountTarEntries(string tarPath) long totalFiles = 0; using var tarStream = File.OpenRead(tarPath); var tarReader = new TarReader(tarStream); - while (tarReader.GetNextEntry() != null) + while (tarReader.GetNextEntry() is not null) { totalFiles++; } @@ -271,7 +271,7 @@ private void ExtractTarContents(string tarPath, string targetDir, MuxerHandlingC var tarReader = new TarReader(tarStream); TarEntry? entry; - while ((entry = tarReader.GetNextEntry()) != null) + while ((entry = tarReader.GetNextEntry()) is not null) { if (entry.EntryType == TarEntryType.RegularFile) { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 162c2b8e80b2..f2defae58c3f 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -11,13 +11,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; namespace Microsoft.Dotnet.Installation.Internal; /// /// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. /// -internal class ReleaseManifest : IDisposable +internal class ReleaseManifest(HttpClient httpClient) : IDisposable { /// /// Parses a version channel string into its components. @@ -230,8 +231,6 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return GetLatestStableVersion(productIndex, component); } - // Parse the channel string into components - var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); // If major is invalid, return null @@ -262,7 +261,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } // Case 3: Feature band version (e.g., "9.0.1xx") - if (minor >= 0 && featureBand != null) + if (minor >= 0 && featureBand is not null) { return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); } @@ -278,7 +277,7 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Get products matching the major version var matchingProducts = GetProductsForMajorVersion(index, major); - if (!matchingProducts.Any()) + if (matchingProducts.Count == 0) { return null; } @@ -563,32 +562,15 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma } } - private const string CacheSubdirectory = "dotnet-manifests"; private const int MaxRetryCount = 3; private const int RetryDelayMilliseconds = 1000; - private const string ReleaseCacheMutexName = "Global\\DotNetReleaseCache"; - private readonly HttpClient _httpClient; - private readonly string _cacheDirectory; + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); private ProductCollection? _productCollection; public ReleaseManifest() - : this(CreateDefaultHttpClient(), GetDefaultCacheDirectory()) - { - } - - public ReleaseManifest(HttpClient httpClient) - : this(httpClient, GetDefaultCacheDirectory()) - { - } - - public ReleaseManifest(HttpClient httpClient, string cacheDirectory) + : this(CreateDefaultHttpClient()) { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _cacheDirectory = cacheDirectory ?? throw new ArgumentNullException(nameof(cacheDirectory)); - - // Ensure cache directory exists - Directory.CreateDirectory(_cacheDirectory); } /// @@ -598,39 +580,24 @@ private static HttpClient CreateDefaultHttpClient() { var handler = new HttpClientHandler() { - // Use system proxy settings by default UseProxy = true, - // Use default credentials for proxy authentication if needed UseDefaultCredentials = true, - // Handle redirects automatically AllowAutoRedirect = true, - // Set maximum number of redirects to prevent infinite loops MaxAutomaticRedirections = 10, - // Enable decompression for better performance AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; var client = new HttpClient(handler) { - // Set a reasonable timeout for downloads Timeout = TimeSpan.FromMinutes(10) }; - // Set user agent to identify the client + // Set user-agent to identify dnup in telemetry client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer"); return client; } - /// - /// Gets the default cache directory path. - /// - private static string GetDefaultCacheDirectory() - { - var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(baseDir, "dnup", CacheSubdirectory); - } - /// /// Downloads the releases.json manifest and finds the download URL for the specified installation. /// @@ -827,59 +794,13 @@ public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, /// private ProductCollection GetProductCollection() { - if (_productCollection != null) + if (_productCollection is not null) { return _productCollection; } - // Use ScopedMutex for cross-process locking - using var mutex = new ScopedMutex(ReleaseCacheMutexName); - - // Always use the index manifest for ProductCollection - for (int attempt = 1; attempt <= MaxRetryCount; attempt++) - { - try - { - _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return _productCollection; - } - catch - { - if (attempt == MaxRetryCount) - { - throw; - } - Thread.Sleep(RetryDelayMilliseconds * attempt); // Exponential backoff - } - } - - // This shouldn't be reached due to throw above, but compiler doesn't know that - throw new InvalidOperationException("Failed to fetch .NET releases data"); - } - - /// - /// Serializes a ProductCollection to JSON. - /// - private static string SerializeProductCollection(ProductCollection collection) - { - // Use options that indicate we've verified AOT compatibility - var options = new System.Text.Json.JsonSerializerOptions(); -#pragma warning disable IL2026, IL3050 - return System.Text.Json.JsonSerializer.Serialize(collection, options); -#pragma warning restore IL2026, IL3050 - } - - /// - /// Deserializes a ProductCollection from JSON. - /// - private static ProductCollection DeserializeProductCollection(string json) - { - // Use options that indicate we've verified AOT compatibility - var options = new System.Text.Json.JsonSerializerOptions(); -#pragma warning disable IL2026, IL3050 - return System.Text.Json.JsonSerializer.Deserialize(json, options) - ?? throw new InvalidOperationException("Failed to deserialize ProductCollection from JSON"); -#pragma warning restore IL2026, IL3050 + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return _productCollection; } /// @@ -894,80 +815,42 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the specific release for the given version. /// - private static ProductRelease? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) + private static ReleaseComponent? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Get all releases - var allReleases = releases.ToList(); - - // First try to find the exact version in the original release list - var exactReleaseMatch = allReleases.FirstOrDefault(r => r.Version.Equals(resolvedVersion)); - if (exactReleaseMatch != null) - { - return exactReleaseMatch; - } + var releases = product.GetReleasesAsync().GetAwaiter().GetResult().ToList(); - // Now check through the releases to find matching components - foreach (var release in allReleases) + foreach (var release in releases) { - bool foundMatch = false; - - // Check the appropriate collection based on the mode if (component == InstallComponent.SDK) { foreach (var sdk in release.Sdks) { - // Check for exact match if (sdk.Version.Equals(resolvedVersion)) { - foundMatch = true; - break; + return sdk; } - - // Not sure what the point of the below logic was - //// Check for match on major, minor, patch - //if (sdk.Version.Major == targetReleaseVersion.Major && - // sdk.Version.Minor == targetReleaseVersion.Minor && - // sdk.Version.Patch == targetReleaseVersion.Patch) - //{ - // foundMatch = true; - // break; - //} } } - else // Runtime mode + else { - // Get the appropriate runtime components based on the file patterns - var filteredRuntimes = release.Runtimes; - - // Use the type information from the file names to filter runtime components - // This will prioritize matching the exact runtime type the user is looking for - - foreach (var runtime in filteredRuntimes) + var runtimesQuery = component switch + { + InstallComponent.ASPNETCore => release.Runtimes + .Where(r => r.Name.Contains("ASP", StringComparison.OrdinalIgnoreCase)), + InstallComponent.WindowsDesktop => release.Runtimes + .Where(r => r.Name.Contains("Desktop", StringComparison.OrdinalIgnoreCase)), + _ => release.Runtimes + .Where(r => r.Name.Contains(".NET Runtime", StringComparison.OrdinalIgnoreCase) || + r.Name.Contains(".NET Core Runtime", StringComparison.OrdinalIgnoreCase)), + }; + foreach (var runtime in runtimesQuery) { - // Check for exact match if (runtime.Version.Equals(resolvedVersion)) { - foundMatch = true; - break; + return runtime; } - - //// Check for match on major, minor, patch - //if (runtime.Version.Major == targetReleaseVersion.Major && - // runtime.Version.Minor == targetReleaseVersion.Minor && - // runtime.Version.Patch == targetReleaseVersion.Patch) - //{ - // foundMatch = true; - // break; - //} } } - - if (foundMatch) - { - return release; - } } return null; @@ -976,53 +859,22 @@ private static ProductCollection DeserializeProductCollection(string json) /// /// Finds the matching file in the release for the given installation requirements. /// - private static ReleaseFile? FindMatchingFile(ProductRelease release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + private static ReleaseFile? FindMatchingFile(ReleaseComponent release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) { var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture); var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); - // Determine the component type pattern to look for in file names - string componentTypePattern; - if (installRequest.Component == InstallComponent.SDK) - { - componentTypePattern = "sdk"; - } - else // Runtime mode - { - // Determine the specific runtime type based on the release's file patterns - // Default to "runtime" if can't determine more specifically - componentTypePattern = "runtime"; - - // Check if this is specifically an ASP.NET Core runtime - if (installRequest.Component == InstallComponent.ASPNETCore) - { - componentTypePattern = "aspnetcore"; - } - // Check if this is specifically a Windows Desktop runtime - else if (installRequest.Component == InstallComponent.WindowsDesktop) - { - componentTypePattern = "windowsdesktop"; - } - } - - // Filter files based on runtime identifier, component type, and file extension var matchingFiles = release.Files - .Where(f => f.Rid == rid) - .Where(f => f.Name.Contains(componentTypePattern, StringComparison.OrdinalIgnoreCase)) - .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) - .ToList(); + .Where(f => f.Rid == rid) // TODO: Do we support musl here? + .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) + .ToList(); if (matchingFiles.Count == 0) { return null; } - // If we have multiple matching files, prefer the one with the full version in the name - var versionString = resolvedVersion.ToString(); - var bestMatch = matchingFiles.FirstOrDefault(f => f.Name.Contains(versionString, StringComparison.OrdinalIgnoreCase)); - - // If no file has the exact version string, return the first match - return bestMatch ?? matchingFiles.First(); + return matchingFiles.First(); } /// @@ -1071,30 +923,3 @@ public void Dispose() _httpClient?.Dispose(); } } - -/// -/// Represents download progress information. -/// -public readonly struct DownloadProgress -{ - /// - /// Gets the number of bytes downloaded. - /// - public long BytesDownloaded { get; } - - /// - /// Gets the total number of bytes to download, if known. - /// - public long? TotalBytes { get; } - - /// - /// Gets the percentage of download completed, if total size is known. - /// - public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; - - public DownloadProgress(long bytesDownloaded, long? totalBytes) - { - BytesDownloaded = bytesDownloaded; - TotalBytes = totalBytes; - } -} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs index 7301ffb16dc6..9a0fa920ad5d 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs @@ -1,5 +1,6 @@ using System; using Spectre.Console; +using Microsoft.Dotnet.Installation; namespace Microsoft.Dotnet.Installation.Internal { diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs index 9a8af2a0733b..f6909756300d 100644 --- a/src/Installer/dnup/ArchiveInstallationValidator.cs +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -112,13 +112,13 @@ private static void ConfigureHostFxrResolution(string installRoot) return; } - if (AppContext.GetData(HostFxrRuntimeProperty) != null) + if (AppContext.GetData(HostFxrRuntimeProperty) is not null) { return; } string? hostFxrPath = FindHostFxrLibrary(installRoot); - if (hostFxrPath != null) + if (hostFxrPath is not null) { AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); } diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs index c4481b4bc247..e1cb04ec401d 100644 --- a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -35,11 +35,11 @@ public override int Execute() string? resolvedInstallPath = null; string? installPathFromGlobalJson = null; - if (globalJsonInfo?.GlobalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath is not null) { installPathFromGlobalJson = globalJsonInfo.SdkPath; - if (installPathFromGlobalJson != null && _installPath != null && + if (installPathFromGlobalJson is not null && _installPath is not null && !DnupUtilities.PathsEqual(installPathFromGlobalJson, _installPath)) { // TODO: Add parameter to override error @@ -55,7 +55,7 @@ public override int Execute() resolvedInstallPath = _installPath; } - if (resolvedInstallPath == null && currentDotnetInstallRoot != null && currentDotnetInstallRoot.InstallType == InstallType.User) + if (resolvedInstallPath == null && currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentDotnetInstallRoot.Path; @@ -77,14 +77,14 @@ public override int Execute() } string? channelFromGlobalJson = null; - if (globalJsonInfo?.GlobalJsonPath != null) + if (globalJsonInfo?.GlobalJsonPath is not null) { channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath); } bool? resolvedUpdateGlobalJson = null; - if (channelFromGlobalJson != null && _versionOrChannel != null && + if (channelFromGlobalJson is not null && _versionOrChannel is not null && // TODO: Should channel comparison be case-sensitive? !channelFromGlobalJson.Equals(_versionOrChannel, StringComparison.OrdinalIgnoreCase)) { @@ -98,13 +98,13 @@ public override int Execute() string? resolvedChannel = null; - if (channelFromGlobalJson != null) + if (channelFromGlobalJson is not null) { SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; } - else if (_versionOrChannel != null) + else if (_versionOrChannel is not null) { resolvedChannel = _versionOrChannel; } @@ -189,7 +189,7 @@ public override int Execute() if (_interactive) { var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); - if (latestAdminVersion != null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) + if (latestAdminVersion is not null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) { SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs index cb075a5e2807..b3b31d9ee354 100644 --- a/src/Installer/dnup/DnupSharedManifest.cs +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -74,9 +74,9 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v try { var installs = JsonSerializer.Deserialize(json, DnupManifestJsonContext.Default.ListDotnetInstall); - var validInstalls = installs ?? new List(); + var validInstalls = installs ?? []; - if (validator != null) + if (validator is not null) { var invalids = validInstalls.Where(i => !validator.Validate(i)).ToList(); if (invalids.Count > 0) @@ -102,10 +102,12 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v /// Installations that match the specified directory public IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null) { - return GetInstalledVersions(validator) - .Where(install => DnupUtilities.PathsEqual( - Path.GetFullPath(install.InstallRoot.Path!), - Path.GetFullPath(installRoot.Path!))); + // TODO: Manifest read operations should protect against data structure changes and be able to reformat an old manifest version. + var installedVersions = GetInstalledVersions(validator); + var expectedInstallRootPath = Path.GetFullPath(installRoot.Path); + var installedVersionsInRoot = installedVersions + .Where(install => DnupUtilities.PathsEqual(Path.GetFullPath(install.InstallRoot.Path!), expectedInstallRootPath)); + return installedVersionsInRoot; } public void AddInstalledVersion(DotnetInstall version) diff --git a/src/Installer/dnup/IDotnetInstallManager.cs b/src/Installer/dnup/IDotnetInstallManager.cs index 9ae89598aa86..4b8c742dbb7c 100644 --- a/src/Installer/dnup/IDotnetInstallManager.cs +++ b/src/Installer/dnup/IDotnetInstallManager.cs @@ -43,7 +43,7 @@ public class GlobalJsonInfo public string? SdkVersion => GlobalJsonContents?.Sdk?.Version; public bool? AllowPrerelease => GlobalJsonContents?.Sdk?.AllowPrerelease; public string? RollForward => GlobalJsonContents?.Sdk?.RollForward; - public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths != null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; + public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths is not null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; } public record DotnetInstallRootConfiguration( diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index b7da3fab1e10..a38782dc9f20 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -166,4 +166,4 @@ }, ] } -} +} \ No newline at end of file diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index 0de472f98eda..aee8e4bd3092 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -130,7 +130,7 @@ public void TestReusesExistingInstall() firstInstall.ExitCode.Should().Be(0, $"First installation failed with exit code {firstInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(firstInstall)}"); - List firstDnupInstalls = new(); + List firstDnupInstalls = []; // Verify the installation was successful using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs index 5c1dcb83c42d..a0d7f9d7b042 100644 --- a/test/dnup.Tests/Utilities/DnupTestUtilities.cs +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -107,14 +107,14 @@ public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput process.StartInfo.RedirectStandardError = shouldCaptureOutput; process.OutputDataReceived += (_, e) => { - if (e.Data != null) + if (e.Data is not null) { outputBuilder.AppendLine(e.Data); } }; process.ErrorDataReceived += (_, e) => { - if (e.Data != null) + if (e.Data is not null) { outputBuilder.AppendLine(e.Data); } @@ -192,7 +192,7 @@ public static string FormatOutputForAssertions(DnupProcessResult result) => private static string GetRepositoryRoot() { var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); - while (currentDirectory != null) + while (currentDirectory is not null) { if (File.Exists(Path.Combine(currentDirectory.FullName, "sdk.slnx"))) { @@ -240,7 +240,7 @@ private static string LocateDnupAssembly(string repoRoot) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); - if (fallback != null) + if (fallback is not null) { return fallback; } From 84827edb7dd945712511e243fc9ea7dfa07312fd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:27:06 -0700 Subject: [PATCH 129/153] Test does not need to hold onto mutex during validation --- test/dnup.Tests/DnupE2Etest.cs | 41 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index aee8e4bd3092..a146af93fc65 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -200,6 +200,7 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, new UpdateChannel(firstChannel), InstallComponent.SDK, new InstallRequestOptions())); + ReleaseVersion? secondResolved = resolver.Resolve( new DotnetInstallRequest( new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), @@ -228,32 +229,34 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, results[1].ExitCode.Should().Be(0, $"Second concurrent install failed with exit code {results[1].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[1])}"); + var installs = new List(); + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); var installs = manifest.GetInstalledVersions() .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) .ToList(); + } + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); - int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; - installs.Should().HaveCount(expectedInstallCount); - - var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - firstResolved.ToString()!, - secondResolved!.ToString()! - }; - - foreach (var install in installs) - { - install.Component.Should().Be(InstallComponent.SDK); - expectedVersions.Should().Contain(install.Version.ToString()); - DnupTestUtilities.ValidateInstall(install).Should().BeTrue( - $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); - } - - var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); - actualVersions.Should().BeEquivalentTo(expectedVersions); + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); } } From 46128513031f62eb303ce4f13299e540771f6991 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 12:35:10 -0700 Subject: [PATCH 130/153] Simplify release manifest logic --- .../Internal/ReleaseManifest.cs | 40 ++++--------------- test/dnup.Tests/DnupE2Etest.cs | 2 +- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index f2defae58c3f..7a683b3f2de9 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -598,17 +598,6 @@ private static HttpClient CreateDefaultHttpClient() return client; } - /// - /// Downloads the releases.json manifest and finds the download URL for the specified installation. - /// - /// The .NET installation details - /// The download URL for the installer/archive, or null if not found - public string? GetDownloadUrl(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) - { - var targetFile = FindReleaseFile(installRequest, resolvedVersion); - return targetFile?.Address.ToString(); - } - /// /// Downloads the archive from the specified URL to the destination path with progress reporting. /// @@ -616,7 +605,7 @@ private static HttpClient CreateDefaultHttpClient() /// The local path to save the downloaded file /// Optional progress reporting /// True if download was successful, false otherwise - public async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) + protected async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) { // Create temp file path in same directory for atomic move when complete string tempPath = $"{destinationPath}.download"; @@ -734,7 +723,7 @@ public async Task DownloadArchiveAsync(string downloadUrl, string destinat /// The local path to save the downloaded file /// Optional progress reporting /// True if download was successful, false otherwise - public bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) + protected bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) { return DownloadArchiveAsync(downloadUrl, destinationPath, progress).GetAwaiter().GetResult(); } @@ -748,15 +737,11 @@ public bool DownloadArchive(string downloadUrl, string destinationPath, IProgres /// True if download and verification were successful, false otherwise public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) { - // Get the download URL and expected hash - string? downloadUrl = GetDownloadUrl(installRequest, resolvedVersion); - if (string.IsNullOrEmpty(downloadUrl)) - { - return false; - } + var targetFile = FindReleaseFile(installRequest, resolvedVersion); + string? downloadUrl = targetFile?.Address.ToString(); + string? expectedHash = targetFile?.Hash.ToString(); - string? expectedHash = GetArchiveHash(installRequest, resolvedVersion); - if (string.IsNullOrEmpty(expectedHash)) + if (string.IsNullOrEmpty(expectedHash) || string.IsNullOrEmpty(downloadUrl)) { return false; } @@ -877,17 +862,6 @@ private ProductCollection GetProductCollection() return matchingFiles.First(); } - /// - /// Gets the SHA512 hash of the archive for the specified installation. - /// - /// The .NET installation details - /// The SHA512 hash string of the installer/archive, or null if not found - public string? GetArchiveHash(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) - { - var targetFile = FindReleaseFile(installRequest, resolvedVersion); - return targetFile?.Hash; - } - /// /// Computes the SHA512 hash of a file. /// @@ -896,6 +870,8 @@ private ProductCollection GetProductCollection() public static string ComputeFileHash(string filePath) { using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + // TODO: Older runtime versions use a different SHA algorithm. + // Eventually the manifest should indicate which algorithm to use. using var sha512 = SHA512.Create(); byte[] hashBytes = sha512.ComputeHash(fileStream); return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs index a146af93fc65..01790ded49f4 100644 --- a/test/dnup.Tests/DnupE2Etest.cs +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -234,7 +234,7 @@ public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) { var manifest = new DnupSharedManifest(testEnv.ManifestPath); - var installs = manifest.GetInstalledVersions() + installs = manifest.GetInstalledVersions() .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) .ToList(); } From ace55a5f6f6d0dc003d314e9162b7a45ecc9b6ce Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 13:34:13 -0700 Subject: [PATCH 131/153] Extensively simplify product version filtering logic --- .../Internal/ReleaseManifest.cs | 269 +++--------------- 1 file changed, 46 insertions(+), 223 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 7a683b3f2de9..30c9d1b7cf4a 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -206,29 +207,21 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// Latest fully specified version string, or null if not found public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) { - // Check for special channel strings (case insensitive) - if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) { - // Handle LTS (Long-Term Support) channel + var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: true, component); - } - else if (string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) - { - // Handle STS (Standard-Term Support) channel - var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, isLts: false, component); + return GetLatestVersionBySupportStatus(productIndex, releaseType, component); } else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) { - // Handle Preview channel - get the latest preview version var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); return GetLatestPreviewVersion(productIndex, component); } else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) { var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestStableVersion(productIndex, component); + return GetLatestVersionBySupportPhase(productIndex, component); } var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -247,21 +240,15 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma // Load the index manifest var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); - - // Case 1: Major only version (e.g., "9") if (minor < 0) { - return GetLatestVersionForMajorOnly(index, major, component); + return GetLatestVersionForMajorOrMajorMinor(index, major, component); // Major Only (e.g., "9") } - - // Case 2: Major.Minor version (e.g., "9.0") - if (minor >= 0 && featureBand == null) + else if (minor >= 0 && featureBand == null) // Major.Minor (e.g., "9.0") { - return GetLatestVersionForMajorMinor(index, major, minor, component); + return GetLatestVersionForMajorOrMajorMinor(index, major, component, minor); } - - // Case 3: Feature band version (e.g., "9.0.1xx") - if (minor >= 0 && featureBand is not null) + else if (minor >= 0 && featureBand is not null) // Not Fully Qualified Feature band Version (e.g., "9.0.1xx") { return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); } @@ -272,32 +259,11 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// Gets the latest version for a major-only channel (e.g., "9"). /// - private ReleaseVersion? GetLatestVersionForMajorOnly(ProductCollection index, int major, InstallComponent component) + private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null) { - // Get products matching the major version - var matchingProducts = GetProductsForMajorVersion(index, major); - - if (matchingProducts.Count == 0) - { - return null; - } - - // Get all releases from all matching products - var allReleases = new List(); - foreach (var matchingProduct in matchingProducts) - { - allReleases.AddRange(matchingProduct.GetReleasesAsync().GetAwaiter().GetResult()); - } - - // Find the latest version based on mode - if (component == InstallComponent.SDK) - { - return GetLatestSdkVersion(allReleases, major); - } - else // Runtime mode - { - return GetLatestRuntimeVersion(allReleases, major); - } + // Assumption: The manifest is designed so that the first product for a major version will always be latest. + Product? latestProductWithMajor = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")).FirstOrDefault(); + return GetLatestReleaseVersionInProduct(latestProductWithMajor, component); } /// @@ -307,71 +273,10 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) /// InstallComponent.SDK or InstallComponent.Runtime /// Latest stable version string matching the support status, or null if none found - private ReleaseVersion? GetLatestVersionBySupportStatus(ProductCollection index, bool isLts, InstallComponent component) + private static ReleaseVersion? GetLatestVersionBySupportStatus(IEnumerable index, ReleaseType releaseType, InstallComponent component) { - // Get all products - var allProducts = index.ToList(); - - // Use ReleaseType from manifest (dotnetreleases library) - var targetType = isLts ? ReleaseType.LTS : ReleaseType.STS; - var filteredProducts = allProducts - .Where(p => p.ReleaseType == targetType) - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) - { - return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); - } - return 0; - }) - .ToList(); - - // Get all releases from filtered products - foreach (var product in filteredProducts) - { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Filter out preview versions - var stableReleases = releases - .Where(r => !r.IsPreview) - .ToList(); - - if (!stableReleases.Any()) - { - continue; // No stable releases for this product, try next one - } - - // Find latest version based on mode - if (component == InstallComponent.SDK) - { - var sdks = stableReleases - .SelectMany(r => r.Sdks) - .Where(sdk => !sdk.Version.ToString().Contains("-")) // Exclude any preview/RC versions - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else // Runtime mode - { - var runtimes = stableReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => !runtime.Version.ToString().Contains("-")) // Exclude any preview/RC versions - .OrderByDescending(runtime => runtime.Version) - .ToList(); - - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } - } - - return null; // No matching versions found + var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty(); + return GetLatestVersionBySupportPhase(correctPhaseProducts, component); } /// @@ -379,131 +284,49 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma /// /// The product collection to search /// InstallComponent.SDK or InstallComponent.Runtime - /// Latest preview version string, or null if none found - private ReleaseVersion? GetLatestPreviewVersion(ProductCollection index, InstallComponent component) + /// Latest preview or GoLive version string, or null if none found + private ReleaseVersion? GetLatestPreviewVersion(IEnumerable index, InstallComponent component) { - // Get all products - var allProducts = index.ToList(); - - // Order by major and minor version (descending) to get the most recent first - var sortedProducts = allProducts - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var majorVersion)) - { - return majorVersion * 100 + (productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) ? minorVersion : 0); - } - return 0; - }) - .ToList(); - - // Get all releases from products - foreach (var product in sortedProducts) + ReleaseVersion? latestPreviewVersion = GetLatestVersionBySupportPhase(index, component, [SupportPhase.Preview, SupportPhase.GoLive]); + if (latestPreviewVersion is not null) { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Filter for preview versions - var previewReleases = releases - .Where(r => r.IsPreview) - .ToList(); - - if (!previewReleases.Any()) - { - continue; // No preview releases for this product, try next one - } - - // Find latest version based on mode - if (component == InstallComponent.SDK) - { - var sdks = previewReleases - .SelectMany(r => r.Sdks) - .Where(sdk => sdk.Version.ToString().Contains("-")) // Include only preview/RC versions - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else // Runtime mode - { - var runtimes = previewReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => runtime.Version.ToString().Contains("-")) // Include only preview/RC versions - .OrderByDescending(runtime => runtime.Version) - .ToList(); - - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } + return latestPreviewVersion; } - return null; // No preview versions found + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); } /// - /// Gets the latest stable version across all available products. + /// Gets the latest version across all available products that matches the support phase. /// - private ReleaseVersion? GetLatestStableVersion(ProductCollection index, InstallComponent component) + private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component) { - var sortedProducts = index - .OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var major)) - { - var minor = productParts.Length > 1 && int.TryParse(productParts[1], out var minorVersion) - ? minorVersion - : 0; - return major * 100 + minor; - } - return 0; - }) - .ToList(); - - foreach (var product in sortedProducts) - { - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - var stableReleases = releases.Where(r => !r.IsPreview).ToList(); - - if (!stableReleases.Any()) - { - continue; - } + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); + } + /// + /// Gets the latest version across all available products that matches the support phase. + /// + private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component, SupportPhase[] acceptedSupportPhases) + { + // A version in preview/ga/rtm support is considered Go Live and not Active. + var activeSupportProducts = index?.Where(p => acceptedSupportPhases.Contains(p.SupportPhase)); - if (component == InstallComponent.SDK) - { - var sdks = stableReleases - .SelectMany(r => r.Sdks) - .Where(sdk => !sdk.Version.ToString().Contains("-")) - .OrderByDescending(sdk => sdk.Version) - .ToList(); + // The manifest is designed so that the first product will always be latest. + Product? latestActiveSupportProduct = activeSupportProducts?.FirstOrDefault(); - if (sdks.Any()) - { - return sdks.First().Version; - } - } - else - { - var runtimes = stableReleases - .SelectMany(r => r.Runtimes) - .Where(runtime => !runtime.Version.ToString().Contains("-")) - .OrderByDescending(runtime => runtime.Version) - .ToList(); + return GetLatestReleaseVersionInProduct(latestActiveSupportProduct, component); + } - if (runtimes.Any()) - { - return runtimes.First().Version; - } - } - } + private static ReleaseVersion? GetLatestReleaseVersionInProduct(Product? product, InstallComponent component) + { + // Assumption: The latest runtime version will always be the same across runtime components. + ReleaseVersion? latestVersion = component switch + { + InstallComponent.SDK => product?.LatestSdkVersion, + _ => product?.LatestRuntimeVersion + }; - return null; + return latestVersion; } /// From 6ac1ab7e788b889f2d7d6de9db1ec7f26c2345e4 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 14:00:10 -0700 Subject: [PATCH 132/153] even further simplify the version parsing logic --- .../Internal/ReleaseManifest.cs | 215 +++--------------- 1 file changed, 29 insertions(+), 186 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 30c9d1b7cf4a..b0eadacc8efd 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -53,152 +53,6 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable return (major, minor, featureBand, isFullySpecified); } - /// - /// Gets products from the index that match the specified major version. - /// - /// The product collection to search - /// The major version to match - /// List of matching products, ordered by minor version (descending) - private List GetProductsForMajorVersion(ProductCollection index, int major) - { - var matchingProducts = index.Where(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 0 && int.TryParse(productParts[0], out var productMajor)) - { - return productMajor == major; - } - return false; - }).ToList(); - - // Order by minor version (descending) to prioritize newer versions - return matchingProducts.OrderByDescending(p => - { - var productParts = p.ProductVersion.Split('.'); - if (productParts.Length > 1 && int.TryParse(productParts[1], out var productMinor)) - { - return productMinor; - } - return 0; - }).ToList(); - } - - /// - /// Gets all SDK components from the releases and returns the latest one. - /// - /// List of releases to search - /// Optional major version filter - /// Optional minor version filter - /// Latest SDK version string, or null if none found - private ReleaseVersion? GetLatestSdkVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null) - { - var allSdks = releases - .SelectMany(r => r.Sdks) - .Where(sdk => - (!majorFilter.HasValue || sdk.Version.Major == majorFilter.Value) && - (!minorFilter.HasValue || sdk.Version.Minor == minorFilter.Value)) - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (allSdks.Any()) - { - return allSdks.First().Version; - } - - return null; - } - - /// - /// Gets all runtime components from the releases and returns the latest one. - /// - /// List of releases to search - /// Optional major version filter - /// Optional minor version filter - /// Optional runtime type filter (null for any runtime) - /// Latest runtime version string, or null if none found - private ReleaseVersion? GetLatestRuntimeVersion(IEnumerable releases, int? majorFilter = null, int? minorFilter = null, string? runtimeType = null) - { - var allRuntimes = releases.SelectMany(r => r.Runtimes).ToList(); - - // Filter by version constraints if provided - if (majorFilter.HasValue) - { - allRuntimes = allRuntimes.Where(r => r.Version.Major == majorFilter.Value).ToList(); - } - - if (minorFilter.HasValue) - { - allRuntimes = allRuntimes.Where(r => r.Version.Minor == minorFilter.Value).ToList(); - } - - // Filter by runtime type if specified - if (!string.IsNullOrEmpty(runtimeType)) - { - if (string.Equals(runtimeType, "aspnetcore", StringComparison.OrdinalIgnoreCase)) - { - allRuntimes = allRuntimes - .Where(r => r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - else if (string.Equals(runtimeType, "windowsdesktop", StringComparison.OrdinalIgnoreCase)) - { - allRuntimes = allRuntimes - .Where(r => r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - else // Regular runtime - { - allRuntimes = allRuntimes - .Where(r => !r.GetType().Name.Contains("AspNetCore", StringComparison.OrdinalIgnoreCase) && - !r.GetType().Name.Contains("WindowsDesktop", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - } - - if (allRuntimes.Any()) - { - return allRuntimes.OrderByDescending(r => r.Version).First().Version; - } - - return null; - } - - /// - /// Gets the latest SDK version that matches a specific feature band pattern. - /// - /// List of releases to search - /// Major version - /// Minor version - /// Feature band prefix (e.g., "1" for "1xx") - /// Latest matching version string, or fallback format if none found - private ReleaseVersion? GetLatestFeatureBandVersion(IEnumerable releases, int major, int minor, string featureBand) - { - var allSdkComponents = releases.SelectMany(r => r.Sdks).ToList(); - - // Filter by feature band - var featureBandSdks = allSdkComponents - .Where(sdk => - { - var version = sdk.Version.ToString(); - var versionParts = version.Split('.'); - if (versionParts.Length < 3) return false; - - var patchPart = versionParts[2].Split('-')[0]; // Remove prerelease suffix - return patchPart.Length >= 3 && patchPart.StartsWith(featureBand); - }) - .OrderByDescending(sdk => sdk.Version) - .ToList(); - - if (featureBandSdks.Any()) - { - // Return the exact version from the latest matching SDK - return featureBandSdks.First().Version; - } - - // Fallback if no actual release matches the feature band pattern - return null; - } - /// /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). /// @@ -256,13 +110,19 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return null; } + private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null) + { + var validProducts = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")); + return validProducts; + } + /// /// Gets the latest version for a major-only channel (e.g., "9"). /// private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null) { // Assumption: The manifest is designed so that the first product for a major version will always be latest. - Product? latestProductWithMajor = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")).FirstOrDefault(); + Product? latestProductWithMajor = GetProductsInMajorOrMajorMinor(index, major, minor).FirstOrDefault(); return GetLatestReleaseVersionInProduct(latestProductWithMajor, component); } @@ -329,60 +189,43 @@ private List GetProductsForMajorVersion(ProductCollection index, int ma return latestVersion; } - /// - /// Gets the latest version for a major.minor channel (e.g., "9.0"). - /// - private ReleaseVersion? GetLatestVersionForMajorMinor(ProductCollection index, int major, int minor, InstallComponent component) + private static string? NormalizeFeatureBandInput(string band) { - // Find the product for the requested major.minor - string channelKey = $"{major}.{minor}"; - var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); - - if (product == null) - { - return null; - } - - // Load releases from the sub-manifest for this product - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); - - // Find the latest version based on mode - if (component == InstallComponent.SDK) - { - return GetLatestSdkVersion(releases, major, minor); - } - else // Runtime mode - { - return GetLatestRuntimeVersion(releases, major, minor); - } + return band? + .Replace("X", "x") + .Replace("x", "0") + .PadRight(3, '0') + .Substring(0, 3); } + /// /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). /// private ReleaseVersion? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallComponent component) { - // Find the product for the requested major.minor - string channelKey = $"{major}.{minor}"; - var product = index.FirstOrDefault(p => p.ProductVersion == channelKey); - - if (product == null) + if (component != InstallComponent.SDK) { return null; } - // Load releases from the sub-manifest for this product - var releases = product.GetReleasesAsync().GetAwaiter().GetResult(); + var validProducts = GetProductsInMajorOrMajorMinor(index, major, minor); + var latestProduct = validProducts.FirstOrDefault(); + var releases = latestProduct?.GetReleasesAsync().GetAwaiter().GetResult().ToList() ?? new List(); + var normalizedFeatureBand = NormalizeFeatureBandInput(featureBand); - // For SDK mode, use feature band filtering - if (component == InstallComponent.SDK) - { - return GetLatestFeatureBandVersion(releases, major, minor, featureBand); - } - else // For Runtime mode, just use regular major.minor filtering + foreach (var release in releases) { - return GetLatestRuntimeVersion(releases, major, minor); + foreach (var sdk in release.Sdks) + { + if (sdk.Version.SdkFeatureBand == int.Parse(normalizedFeatureBand ?? "0")) + { + return sdk.Version; + } + } } + + return null; } private const int MaxRetryCount = 3; From d4daa387126770ed9b1a8cec1c1c81aad5cb0443 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 15:38:39 -0700 Subject: [PATCH 133/153] Copy the logic for sdk CI to dnup CI --- .vsts-dnup-ci.yml | 265 ++++++++++++++++++ .../templates/jobs/dnup-library-package.yml | 0 2 files changed, 265 insertions(+) create mode 100644 .vsts-dnup-ci.yml create mode 100644 eng/pipelines/templates/jobs/dnup-library-package.yml diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml new file mode 100644 index 000000000000..203f25858867 --- /dev/null +++ b/.vsts-dnup-ci.yml @@ -0,0 +1,265 @@ +# Pipeline: https://dev.azure.com/dnceng/internal/_build?definitionId= + +trigger: + batch: true + branches: + include: + - dnup + +pr: + branches: + include: + - dnup + +parameters: +# When true, runs the pipeline in the same way as the PR pipeline. +- name: runTestBuild + displayName: Run A Test Build + type: boolean + default: false +- name: enableArm64Job + displayName: Enables the ARM64 job + type: boolean + default: false + +variables: +- template: /eng/pipelines/templates/variables/sdk-defaults.yml +# Variables used: DncEngInternalBuildPool +- template: /eng/common/templates-official/variables/pool-providers.yml +# Helix testing requires a token when internally run. +# Variables used: HelixApiAccessToken +- group: DotNet-HelixApi-Access +- group: AzureDevOps-Artifact-Feeds-Pats +# Allows Arcade to run a signed build by disabling post-build signing for release-branch builds or manual builds that are not running tests. +- ${{ if and(eq(parameters.runTestBuild, false), or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), eq(variables['Build.Reason'], 'Manual'))) }}: + - name: PostBuildSign + value: false +# Provides TSA variables for automatic bug reporting. +- ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + - group: DotNet-CLI-SDLValidation-Params +### LOCAL ONLY ### +- name: _publishArgument + value: -publish +- name: _signArgument + value: -sign /p:SignCoreSdk=true +- name: _officialBuildProperties + # The OfficialBuilder property is set to Microsoft for the official build only. + # This property is checked in Directory.Build.props and adds the MICROSOFT_ENABLE_TELEMETRY constant. + # This constant is used in CompileOptions.cs to set both TelemetryOptOutDefault and TelemetryOptOutDefaultString. + value: /p:DotNetPublishUsingPipelines=true /p:OfficialBuilder=Microsoft /p:OfficialBuildId=$(Build.BuildNumber) + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + ${{ else }}: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines + parameters: + containers: + azureLinux30Amd64: + image: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-build-amd64 + + sdl: + sourceAnalysisPool: + name: $(DncEngInternalBuildPool) + image: 1es-windows-2022 + os: windows + policheck: + enabled: true + tsa: + enabled: true + binskim: + enabled: true + ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}: + componentgovernance: + # Refdoc: https://docs.opensource.microsoft.com/tools/cg/component-detection/variables/ + ignoreDirectories: artifacts, .packages + + stages: + ############### BUILD STAGE ############### + - stage: build + displayName: Build + jobs: + ############### WINDOWS ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + parameters: + pool: + name: $(DncEngInternalBuildPool) + image: windows.vs2022.amd64 + os: windows + helixTargetQueue: windows.amd64.vs2022.pre + oneESCompat: + templateFolderName: templates-official + publishTaskPrefix: 1ES. + runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) + locBranch: release/10.0.1xx + # WORKAROUND: BinSkim requires the folder exist prior to scanning. + preSteps: + - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force + displayName: Create artifacts/bin directory + ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: + timeoutInMinutes: 180 + windowsJobParameterSets: + ### OFFICIAL ### + - categoryName: Official + publishArgument: $(_publishArgument) + signArgument: $(_signArgument) + officialBuildProperties: $(_officialBuildProperties) /p:BuildWorkloads=true + enableDefaultArtifacts: true + runTests: false + publishRetryConfig: true + variables: + _SignType: real + - categoryName: Official + targetArchitecture: x86 + publishArgument: $(_publishArgument) + signArgument: $(_signArgument) + officialBuildProperties: $(_officialBuildProperties) + runTests: false + variables: + _SignType: real + dependsOn: Official_windows_x64 + downloadManifestMsiPackages: true + - categoryName: Official + targetArchitecture: arm64 + publishArgument: $(_publishArgument) + signArgument: $(_signArgument) + officialBuildProperties: $(_officialBuildProperties) + runTests: false + variables: + _SignType: real + dependsOn: Official_windows_x64 + downloadManifestMsiPackages: true + + ############### LINUX ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + parameters: + pool: + name: $(DncEngInternalBuildPool) + image: 1es-ubuntu-2204 + os: linux + helixTargetQueue: ubuntu.2204.amd64 + oneESCompat: + templateFolderName: templates-official + publishTaskPrefix: 1ES. + runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) + ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: + timeoutInMinutes: 90 + linuxJobParameterSets: + ### OFFICIAL ### + # Note: These builds are also glibc like the glibc category, but that category uses containers, and doesn't publish zips and tarballs. + - categoryName: Official + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + osProperties: $(linuxOsglibcProperties) + runTests: false + - categoryName: Official + targetArchitecture: arm + runtimeIdentifier: linux-arm + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + osProperties: $(linuxOsglibcProperties) + runTests: false + - categoryName: Official + targetArchitecture: arm64 + runtimeIdentifier: linux-arm64 + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + osProperties: $(linuxOsglibcProperties) + runTests: false + ### glibc ### + - categoryName: glibc + # Do not publish zips and tarballs. The linux-x64 binaries are already published by Official. + publishArgument: $(_publishArgument) /p:PublishBinariesAndBadge=false + officialBuildProperties: $(_officialBuildProperties) + osProperties: $(linuxOsglibcProperties) /p:BuildSdkDeb=true + runTests: false + - categoryName: glibc + targetArchitecture: arm64 + runtimeIdentifier: linux-arm64 + # Do not publish zips and tarballs. The linux-arm64 binaries are already published by Official. + publishArgument: $(_publishArgument) /p:PublishBinariesAndBadge=false + officialBuildProperties: $(_officialBuildProperties) + osProperties: $(linuxOsglibcProperties) /p:BuildSdkDeb=true + runTests: false + ### musl ### + - categoryName: musl + container: azureLinux30Amd64 + runtimeIdentifier: linux-musl-x64 + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + # Use HostOSName when running on alpine. + osProperties: /p:HostOSName=linux-musl + # SBOM generation is not supported for alpine. + enableSbom: false + runTests: false + # Helix is hanging on this job using the container. See: https://github.com/dotnet/dnceng/issues/6000 + disableJob: true + - categoryName: musl + container: azureLinux30Amd64 + targetArchitecture: arm + runtimeIdentifier: linux-musl-arm + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + osProperties: /p:OSName=linux-musl + runTests: false + # Helix is hanging on this job using the container. See: https://github.com/dotnet/dnceng/issues/6000 + disableJob: true + - categoryName: musl + targetArchitecture: arm64 + runtimeIdentifier: linux-musl-arm64 + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + osProperties: /p:OSName=linux-musl + runTests: false + + ############### MACOS ############### + - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + parameters: + pool: + name: Azure Pipelines + image: macOS-latest + os: macOS + helixTargetQueue: osx.15.amd64 + oneESCompat: + templateFolderName: templates-official + publishTaskPrefix: 1ES. + runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) + ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: + timeoutInMinutes: 90 + macOSJobParameterSets: + ### OFFICIAL ### + - categoryName: Official + runtimeIdentifier: osx-x64 + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + runTests: false + - categoryName: Official + targetArchitecture: arm64 + runtimeIdentifier: osx-arm64 + publishArgument: $(_publishArgument) + officialBuildProperties: $(_officialBuildProperties) + runTests: false + + ############### PUBLISH STAGE ############### + - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + - stage: publish + displayName: Publish + dependsOn: [] + jobs: + - template: /eng/common/templates-official/job/publish-build-assets.yml@self + parameters: + publishUsingPipelines: true + publishAssetsImmediately: true + isAssetlessBuild: true + repositoryAlias: self + pool: + name: $(DncEngInternalBuildPool) + image: 1es-windows-2022 + os: windows diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml new file mode 100644 index 000000000000..e69de29bb2d1 From fd0d9eecc3dcc2a5367e883870155a07d7df5cd3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 15:38:50 -0700 Subject: [PATCH 134/153] Produce a package from the library on build --- .../Microsoft.Dotnet.Installation.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj index a5b18eda75df..49a69132ae20 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -8,11 +8,13 @@ $(NoWarn);CS8002 + Microsoft.Dotnet.Installation + true - + From 67a61aba0c55c402b7d9a35b890284d32f7398fc Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 16:08:17 -0700 Subject: [PATCH 135/153] enable package creation --- .../Microsoft.Dotnet.Installation.csproj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj index 49a69132ae20..e1609c39b30c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -8,8 +8,12 @@ $(NoWarn);CS8002 + true + true Microsoft.Dotnet.Installation - true + false + .NET Installation Library + 1.0.0-alpha From 4003434c6193a9b47e682a3834e2ddb0f1724335 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 16:19:41 -0700 Subject: [PATCH 136/153] Package version correctly created at D:\sdk\artifacts\packages\Release\NonShipping --- .../Microsoft.Dotnet.Installation.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj index e1609c39b30c..ec1119864671 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -14,6 +14,7 @@ false .NET Installation Library 1.0.0-alpha + $(Version) From 8cdbcf4f3ead962fd12f9ba378d1396a37a33e68 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 16:32:41 -0700 Subject: [PATCH 137/153] Simplify package creation for now to only run win tests / pkg --- .vsts-dnup-ci.yml | 145 +++------------------------------------------- 1 file changed, 7 insertions(+), 138 deletions(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index 203f25858867..2ae6f87cf649 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -5,11 +5,13 @@ trigger: branches: include: - dnup + - release/dnup pr: branches: include: - dnup + - release/dnup parameters: # When true, runs the pipeline in the same way as the PR pipeline. @@ -83,22 +85,19 @@ extends: stages: ############### BUILD STAGE ############### - - stage: build - displayName: Build - jobs: ############### WINDOWS ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: pool: - name: $(DncEngInternalBuildPool) + name: $($(DncEngInternalBuildPool)) image: windows.vs2022.amd64 os: windows + emoji: 🪟 helixTargetQueue: windows.amd64.vs2022.pre oneESCompat: templateFolderName: templates-official publishTaskPrefix: 1ES. runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) - locBranch: release/10.0.1xx # WORKAROUND: BinSkim requires the folder exist prior to scanning. preSteps: - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force @@ -116,144 +115,14 @@ extends: publishRetryConfig: true variables: _SignType: real - - categoryName: Official - targetArchitecture: x86 - publishArgument: $(_publishArgument) - signArgument: $(_signArgument) - officialBuildProperties: $(_officialBuildProperties) - runTests: false - variables: - _SignType: real - dependsOn: Official_windows_x64 - downloadManifestMsiPackages: true - - categoryName: Official - targetArchitecture: arm64 - publishArgument: $(_publishArgument) - signArgument: $(_signArgument) - officialBuildProperties: $(_officialBuildProperties) - runTests: false - variables: - _SignType: real - dependsOn: Official_windows_x64 - downloadManifestMsiPackages: true - - ############### LINUX ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self - parameters: - pool: - name: $(DncEngInternalBuildPool) - image: 1es-ubuntu-2204 - os: linux - helixTargetQueue: ubuntu.2204.amd64 - oneESCompat: - templateFolderName: templates-official - publishTaskPrefix: 1ES. - runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) - ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: - timeoutInMinutes: 90 - linuxJobParameterSets: - ### OFFICIAL ### - # Note: These builds are also glibc like the glibc category, but that category uses containers, and doesn't publish zips and tarballs. - - categoryName: Official - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - osProperties: $(linuxOsglibcProperties) - runTests: false - - categoryName: Official - targetArchitecture: arm - runtimeIdentifier: linux-arm - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - osProperties: $(linuxOsglibcProperties) - runTests: false - - categoryName: Official - targetArchitecture: arm64 - runtimeIdentifier: linux-arm64 - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - osProperties: $(linuxOsglibcProperties) - runTests: false - ### glibc ### - - categoryName: glibc - # Do not publish zips and tarballs. The linux-x64 binaries are already published by Official. - publishArgument: $(_publishArgument) /p:PublishBinariesAndBadge=false - officialBuildProperties: $(_officialBuildProperties) - osProperties: $(linuxOsglibcProperties) /p:BuildSdkDeb=true - runTests: false - - categoryName: glibc - targetArchitecture: arm64 - runtimeIdentifier: linux-arm64 - # Do not publish zips and tarballs. The linux-arm64 binaries are already published by Official. - publishArgument: $(_publishArgument) /p:PublishBinariesAndBadge=false - officialBuildProperties: $(_officialBuildProperties) - osProperties: $(linuxOsglibcProperties) /p:BuildSdkDeb=true - runTests: false - ### musl ### - - categoryName: musl - container: azureLinux30Amd64 - runtimeIdentifier: linux-musl-x64 - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - # Use HostOSName when running on alpine. - osProperties: /p:HostOSName=linux-musl - # SBOM generation is not supported for alpine. - enableSbom: false - runTests: false - # Helix is hanging on this job using the container. See: https://github.com/dotnet/dnceng/issues/6000 - disableJob: true - - categoryName: musl - container: azureLinux30Amd64 - targetArchitecture: arm - runtimeIdentifier: linux-musl-arm - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - osProperties: /p:OSName=linux-musl - runTests: false - # Helix is hanging on this job using the container. See: https://github.com/dotnet/dnceng/issues/6000 - disableJob: true - - categoryName: musl - targetArchitecture: arm64 - runtimeIdentifier: linux-musl-arm64 - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - osProperties: /p:OSName=linux-musl - runTests: false - ############### MACOS ############### - - template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self - parameters: - pool: - name: Azure Pipelines - image: macOS-latest - os: macOS - helixTargetQueue: osx.15.amd64 - oneESCompat: - templateFolderName: templates-official - publishTaskPrefix: 1ES. - runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) - ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: - timeoutInMinutes: 90 - macOSJobParameterSets: - ### OFFICIAL ### - - categoryName: Official - runtimeIdentifier: osx-x64 - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - runTests: false - - categoryName: Official - targetArchitecture: arm64 - runtimeIdentifier: osx-arm64 - publishArgument: $(_publishArgument) - officialBuildProperties: $(_officialBuildProperties) - runTests: false - - ############### PUBLISH STAGE ############### + ############### PACKAGE STAGE ############### - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: - stage: publish displayName: Publish dependsOn: [] jobs: - - template: /eng/common/templates-official/job/publish-build-assets.yml@self + - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self parameters: publishUsingPipelines: true publishAssetsImmediately: true From ae2f079718aa147e73f6781d94756b4e2c89f989 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 22 Oct 2025 16:48:48 -0700 Subject: [PATCH 138/153] remove unused publish properties --- .vsts-dnup-ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index 2ae6f87cf649..f0523037a8d6 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -124,10 +124,6 @@ extends: jobs: - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self parameters: - publishUsingPipelines: true - publishAssetsImmediately: true - isAssetlessBuild: true - repositoryAlias: self pool: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 From c689d8f1f093c16dcebdab9b142445115c8c67ae Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 23 Oct 2025 12:31:26 -0700 Subject: [PATCH 139/153] Address Feedback --- .../Internal/ReleaseManifest.cs | 25 +++++++++++++------ src/Installer/dnup/DnupDebugHelper.cs | 4 --- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index b0eadacc8efd..243e49da0766 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -65,7 +65,7 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable { var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportStatus(productIndex, releaseType, component); + return GetLatestVersionByReleaseType(productIndex, releaseType, component); } else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) { @@ -75,7 +75,7 @@ internal class ReleaseManifest(HttpClient httpClient) : IDisposable else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) { var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); - return GetLatestVersionBySupportPhase(productIndex, component); + return GetLatestActiveVersion(productIndex, component); } var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); @@ -133,10 +133,10 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) /// InstallComponent.SDK or InstallComponent.Runtime /// Latest stable version string matching the support status, or null if none found - private static ReleaseVersion? GetLatestVersionBySupportStatus(IEnumerable index, ReleaseType releaseType, InstallComponent component) + private static ReleaseVersion? GetLatestVersionByReleaseType(IEnumerable index, ReleaseType releaseType, InstallComponent component) { var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty(); - return GetLatestVersionBySupportPhase(correctPhaseProducts, component); + return GetLatestActiveVersion(correctPhaseProducts, component); } /// @@ -159,7 +159,7 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable /// /// Gets the latest version across all available products that matches the support phase. /// - private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component) + private static ReleaseVersion? GetLatestActiveVersion(IEnumerable index, InstallComponent component) { return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); } @@ -189,13 +189,22 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable return latestVersion; } - private static string? NormalizeFeatureBandInput(string band) + /// + /// Replaces user input feature band strings into the full feature band. + /// This would convert '1xx' into '100'. + /// 100 is not necessarily the latest but it is the feature band. + /// The other number in the band is the patch. + /// + /// + /// + private static int NormalizeFeatureBandInput(string band) { - return band? + var bandString = band .Replace("X", "x") .Replace("x", "0") .PadRight(3, '0') .Substring(0, 3); + return int.Parse(bandString); } @@ -218,7 +227,7 @@ private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable { foreach (var sdk in release.Sdks) { - if (sdk.Version.SdkFeatureBand == int.Parse(normalizedFeatureBand ?? "0")) + if (sdk.Version.SdkFeatureBand == normalizedFeatureBand) { return sdk.Version; } diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs index 4013838a227d..08810333fa3c 100644 --- a/src/Installer/dnup/DnupDebugHelper.cs +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -22,11 +22,7 @@ public static void HandleDebugSwitch(ref string[] args) public static void WaitForDebugger() { -#if NET5_0_OR_GREATER int processId = Environment.ProcessId; -#else - int processId = Process.GetCurrentProcess().Id; -#endif Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); Console.WriteLine($"Process ID: {processId}"); From 06d440a86cf0ac0fee8a90618476e4c3b0c8a7ef Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 23 Oct 2025 12:34:29 -0700 Subject: [PATCH 140/153] add to dnup slnf --- dnup.slnf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dnup.slnf b/dnup.slnf index b38b36feec3d..8384b13f95f0 100644 --- a/dnup.slnf +++ b/dnup.slnf @@ -5,6 +5,7 @@ "src\\Installer\\dnup\\dnup.csproj", "src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj", "test\\dnup.Tests\\dnup.Tests.csproj", + "src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj ] } -} \ No newline at end of file +} From 4bd59c7dfda0b61fe74b330fb648a0867d9b2f7a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 10:16:17 -0700 Subject: [PATCH 141/153] Fix whitespace and package name --- .vsts-dnup-ci.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index f0523037a8d6..c1b0c038f30b 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -84,8 +84,7 @@ extends: ignoreDirectories: artifacts, .packages stages: - ############### BUILD STAGE ############### - ############### WINDOWS ############### + ### Windows ### - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self parameters: pool: @@ -115,11 +114,9 @@ extends: publishRetryConfig: true variables: _SignType: real - - ############### PACKAGE STAGE ############### - - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: - - stage: publish - displayName: Publish + ### Packaging ### + - stage: package + displayName: 📦 Package dnup dependsOn: [] jobs: - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self From 2f8b00be0b0a941e3c35b6d8d5c493bab0ca0f12 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 10:26:50 -0700 Subject: [PATCH 142/153] See if we need a separate package step or not, move into job --- .vsts-dnup-ci.yml | 63 ++++++++++--------- .../templates/jobs/dnup-library-package.yml | 58 +++++++++++++++++ 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index c1b0c038f30b..1dca7cbce5ee 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -84,36 +84,39 @@ extends: ignoreDirectories: artifacts, .packages stages: - ### Windows ### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self - parameters: - pool: - name: $($(DncEngInternalBuildPool)) - image: windows.vs2022.amd64 - os: windows - emoji: 🪟 - helixTargetQueue: windows.amd64.vs2022.pre - oneESCompat: - templateFolderName: templates-official - publishTaskPrefix: 1ES. - runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) - # WORKAROUND: BinSkim requires the folder exist prior to scanning. - preSteps: - - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force - displayName: Create artifacts/bin directory - ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: - timeoutInMinutes: 180 - windowsJobParameterSets: - ### OFFICIAL ### - - categoryName: Official - publishArgument: $(_publishArgument) - signArgument: $(_signArgument) - officialBuildProperties: $(_officialBuildProperties) /p:BuildWorkloads=true - enableDefaultArtifacts: true - runTests: false - publishRetryConfig: true - variables: - _SignType: real + - stage: tests + displayName: 🧪 dnup tests + jobs: + ### Windows ### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: $(DncEngInternalBuildPool) + image: windows.vs2022.amd64 + os: windows + emoji: 🪟 + helixTargetQueue: windows.amd64.vs2022.pre + oneESCompat: + templateFolderName: templates-official + publishTaskPrefix: 1ES. + runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) + # WORKAROUND: BinSkim requires the folder exist prior to scanning. + preSteps: + - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force + displayName: Create artifacts/bin directory + ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: + timeoutInMinutes: 180 + windowsJobParameterSets: + ### OFFICIAL ### + - categoryName: Official + publishArgument: $(_publishArgument) + signArgument: $(_signArgument) + officialBuildProperties: $(_officialBuildProperties) /p:BuildWorkloads=true + enableDefaultArtifacts: true + runTests: false + publishRetryConfig: true + variables: + _SignType: real ### Packaging ### - stage: package displayName: 📦 Package dnup diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index e69de29bb2d1..2f79e79ed5ed 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -0,0 +1,58 @@ +parameters: + ### GENERAL ### + variables: {} + dependsOn: '' + helixTargetQueue: '' + oneESCompat: + templateFolderName: templates + publishTaskPrefix: '' + container: '' + helixTargetContainer: '' + categoryName: dnup + runTests: true + publishRetryConfig: false + publishXunitResults: false + enableSbom: true + timeoutInMinutes: 150 + +jobs: +- template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml + parameters: + displayName: '${{ parameters.pool.emoji }} dnup package: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})' + pool: ${{ parameters.pool }} + container: ${{ parameters.container }} + strategy: ${{ parameters.strategy }} + helixRepo: dotnet/sdk + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + enableMicrobuild: true + enablePublishBuildAssets: true + enableTelemetry: true + enablePublishUsingPipelines: true + enableSbom: ${{ parameters.enableSbom }} + variables: + - ${{ insert }}: ${{ parameters.variables }} + dependsOn: ${{ parameters.dependsOn }} + preSteps: ${{ parameters.preSteps }} + templateContext: + sdl: + binskim: + analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; + + steps: + - ${{ if eq(parameters.pool.os, 'windows') }}: + - powershell: | + & .\restore.cmd + displayName: 🍱 Bootstrap toolset (Windows) + - powershell: | + & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj + displayName: ♻️ Restore dnup tests (Windows) + - powershell: | + & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore + displayName: 💻 Build Windows + - task: PublishBuildArtifacts@1 + displayName: ⬇️ Publish test artifacts + condition: always() + inputs: + PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults + ArtifactName: dnupTestResults_${{ parameters.pool.os }} + publishLocation: Container From 53ecd31ce02c7ae62141148e9b5e8d3b550d90b3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 10:28:25 -0700 Subject: [PATCH 143/153] Don't publish any artifact, not allowed as build artifact --- eng/pipelines/templates/jobs/dnup-library-package.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index 2f79e79ed5ed..f875e97249cb 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -49,10 +49,3 @@ jobs: - powershell: | & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore displayName: 💻 Build Windows - - task: PublishBuildArtifacts@1 - displayName: ⬇️ Publish test artifacts - condition: always() - inputs: - PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults - ArtifactName: dnupTestResults_${{ parameters.pool.os }} - publishLocation: Container From aece033f232cb7de267ab9a4b1dc046856356f68 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 10:47:37 -0700 Subject: [PATCH 144/153] use pipeline artifact output --- eng/pipelines/templates/jobs/dnup-tests.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml index 0477def2d581..4b670eac75cb 100644 --- a/eng/pipelines/templates/jobs/dnup-tests.yml +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -37,6 +37,13 @@ jobs: sdl: binskim: analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; + outputs: + - output: pipelineArtifact + displayName: '🌐 Publish Test Artifacts' + condition: always() + targetPath: '$(Build.SourcesDirectory)/artifacts/dnupTestResults' + artifactName: 'dnupTestResults_${{ parameters.pool.os }}' + publishLocation: Container steps: - ${{ if eq(parameters.pool.os, 'windows') }}: @@ -79,10 +86,3 @@ jobs: testResultsFiles: '**/dnup-tests.trx' searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults testRunTitle: 'dnup ${{ parameters.pool.os }}' - - task: PublishBuildArtifacts@1 - displayName: ⬇️ Publish test artifacts - condition: always() - inputs: - PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults - ArtifactName: dnupTestResults_${{ parameters.pool.os }} - publishLocation: Container From 4a81551d37e9c5da1e3d09d88f3db5a76bac05eb Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 10:58:40 -0700 Subject: [PATCH 145/153] Create dnup library package --- eng/pipelines/templates/jobs/dnup-library-package.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index f875e97249cb..2cf2289785d8 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -37,6 +37,13 @@ jobs: sdl: binskim: analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; + outputs: + - output: pipelineArtifact + displayName: '🌐 Publish dnup library package' + condition: always() + targetPath: '$(Build.SourcesDirectory)/artifacts/packages/Release/NonShipping/' + artifactName: 'Microsoft.Dotnet.Installation.1.0.0-alpha.nupkg' # TODO: Replace hard-coded with * or version + publishLocation: Container steps: - ${{ if eq(parameters.pool.os, 'windows') }}: @@ -49,3 +56,6 @@ jobs: - powershell: | & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore displayName: 💻 Build Windows + - powershell: | + & .\.dotnet\dotnet pack .\src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj + displayName: 📦 Package dnup library From 66d859ba71d10dcdc23a17e351fe7d77064233a1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 11:25:08 -0700 Subject: [PATCH 146/153] Create separate arcade build script package step --- .vsts-dnup-ci.yml | 6 ++++++ .../templates/jobs/dnup-library-package.yml | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index 1dca7cbce5ee..ac1e3edff3e5 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -44,11 +44,17 @@ variables: value: -publish - name: _signArgument value: -sign /p:SignCoreSdk=true +- name: _TeamName + value: DotNet-Cli +- name: _BuildConfig + value: Release - name: _officialBuildProperties # The OfficialBuilder property is set to Microsoft for the official build only. # This property is checked in Directory.Build.props and adds the MICROSOFT_ENABLE_TELEMETRY constant. # This constant is used in CompileOptions.cs to set both TelemetryOptOutDefault and TelemetryOptOutDefaultString. value: /p:DotNetPublishUsingPipelines=true /p:OfficialBuilder=Microsoft /p:OfficialBuildId=$(Build.BuildNumber) +- name: _SignType + value: real resources: repositories: diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index 2cf2289785d8..e7392eb234a7 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -39,11 +39,17 @@ jobs: analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; outputs: - output: pipelineArtifact - displayName: '🌐 Publish dnup library package' + displayName: '🌐 Publish dnup library package from standard ci' condition: always() targetPath: '$(Build.SourcesDirectory)/artifacts/packages/Release/NonShipping/' artifactName: 'Microsoft.Dotnet.Installation.1.0.0-alpha.nupkg' # TODO: Replace hard-coded with * or version publishLocation: Container + - output: pipelineArtifact + displayName: '🌏 Publish dnup library package from Arcade' + condition: always() + targetPath: ' $(System.DefaultWorkingDirectory)/artifacts/packages/Release/Shipping/' + artifactName: 'Microsoft.Dotnet.Installation.1.0.0-alpha.nupkg' # TODO: Replace hard-coded with * or version + publishLocation: Container steps: - ${{ if eq(parameters.pool.os, 'windows') }}: @@ -59,3 +65,16 @@ jobs: - powershell: | & .\.dotnet\dotnet pack .\src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj displayName: 📦 Package dnup library + - powershell: >- + eng/common/build.ps1 + -restore -build -sign -pack -publish -ci + -verbosity detailed + -binaryLog + -configuration $(_BuildConfig) + -projects src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj;src\Installer\dnup\dnup.csproj + /p:DotNetSignType=$(_SignType) + /p:TeamName=$(_TeamName) + /p:DotNetPublishUsingPipelines=true + /p:OfficialBuildId=$(OfficialBuildId) + /p:StabilizePackageVersion=${{ parameters.stabilizePackageVersion }} + displayName: 📦 Package dnup library (arcade) From bcf010dfb43754c922c25a9a225617e6d782af7d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 27 Oct 2025 11:29:45 -0700 Subject: [PATCH 147/153] Add link to build def id --- .vsts-dnup-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index ac1e3edff3e5..828824581a31 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -1,4 +1,4 @@ -# Pipeline: https://dev.azure.com/dnceng/internal/_build?definitionId= +# Pipeline: https://dev.azure.com/dnceng/internal/_build?definitionId=1544 trigger: batch: true From 7d9979bbfea25f9d9276607c477c288bc6762e5b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 12:20:58 -0700 Subject: [PATCH 148/153] Produce all packages and separate them based on arcade build --- eng/pipelines/templates/jobs/dnup-library-package.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index e7392eb234a7..c19368317d0b 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -39,16 +39,16 @@ jobs: analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; outputs: - output: pipelineArtifact - displayName: '🌐 Publish dnup library package from standard ci' + displayName: '🌐 Publish dnup library packages' condition: always() targetPath: '$(Build.SourcesDirectory)/artifacts/packages/Release/NonShipping/' - artifactName: 'Microsoft.Dotnet.Installation.1.0.0-alpha.nupkg' # TODO: Replace hard-coded with * or version + artifactName: 'dnup-library-packages' publishLocation: Container - output: pipelineArtifact - displayName: '🌏 Publish dnup library package from Arcade' + displayName: '🌏 Publish dnup library packages (Arcade)' condition: always() - targetPath: ' $(System.DefaultWorkingDirectory)/artifacts/packages/Release/Shipping/' - artifactName: 'Microsoft.Dotnet.Installation.1.0.0-alpha.nupkg' # TODO: Replace hard-coded with * or version + targetPath: '$(System.DefaultWorkingDirectory)/artifacts/packages/Release/Shipping/' + artifactName: 'dnup-library-packages-arcade' publishLocation: Container steps: From af89b4f563bbab5a2661082c20a2f2cd87f117e2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 12:46:30 -0700 Subject: [PATCH 149/153] remove arcade logic - it looks like it's trying to sign without arcade, but the signing step fails because its not a release/* branch --- .../templates/jobs/dnup-library-package.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index c19368317d0b..80d4867d6431 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -44,12 +44,6 @@ jobs: targetPath: '$(Build.SourcesDirectory)/artifacts/packages/Release/NonShipping/' artifactName: 'dnup-library-packages' publishLocation: Container - - output: pipelineArtifact - displayName: '🌏 Publish dnup library packages (Arcade)' - condition: always() - targetPath: '$(System.DefaultWorkingDirectory)/artifacts/packages/Release/Shipping/' - artifactName: 'dnup-library-packages-arcade' - publishLocation: Container steps: - ${{ if eq(parameters.pool.os, 'windows') }}: @@ -65,16 +59,3 @@ jobs: - powershell: | & .\.dotnet\dotnet pack .\src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj displayName: 📦 Package dnup library - - powershell: >- - eng/common/build.ps1 - -restore -build -sign -pack -publish -ci - -verbosity detailed - -binaryLog - -configuration $(_BuildConfig) - -projects src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj;src\Installer\dnup\dnup.csproj - /p:DotNetSignType=$(_SignType) - /p:TeamName=$(_TeamName) - /p:DotNetPublishUsingPipelines=true - /p:OfficialBuildId=$(OfficialBuildId) - /p:StabilizePackageVersion=${{ parameters.stabilizePackageVersion }} - displayName: 📦 Package dnup library (arcade) From 44ccd0c6ff4014722175f517640d18543afb99e5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 12:54:37 -0700 Subject: [PATCH 150/153] No need to restore in CI --- eng/pipelines/templates/jobs/dnup-library-package.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml index 80d4867d6431..d061b4613dbf 100644 --- a/eng/pipelines/templates/jobs/dnup-library-package.yml +++ b/eng/pipelines/templates/jobs/dnup-library-package.yml @@ -51,10 +51,7 @@ jobs: & .\restore.cmd displayName: 🍱 Bootstrap toolset (Windows) - powershell: | - & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj - displayName: ♻️ Restore dnup tests (Windows) - - powershell: | - & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore + & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release displayName: 💻 Build Windows - powershell: | & .\.dotnet\dotnet pack .\src\Installer\Microsoft.Dotnet.Installation\Microsoft.Dotnet.Installation.csproj From 1f6af14260b0f5d0c8a7d0bdd13d5643f507d57c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 13:25:21 -0700 Subject: [PATCH 151/153] Move dnup templates into dnup folder --- .vsts-dnup-ci.yml | 4 ++-- .vsts-dnup-pr.yml | 8 ++++---- .../templates/jobs/{ => dnup}/dnup-library-package.yml | 0 eng/pipelines/templates/jobs/{ => dnup}/dnup-tests.yml | 0 4 files changed, 6 insertions(+), 6 deletions(-) rename eng/pipelines/templates/jobs/{ => dnup}/dnup-library-package.yml (100%) rename eng/pipelines/templates/jobs/{ => dnup}/dnup-tests.yml (100%) diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml index 828824581a31..6af482154a3c 100644 --- a/.vsts-dnup-ci.yml +++ b/.vsts-dnup-ci.yml @@ -94,7 +94,7 @@ extends: displayName: 🧪 dnup tests jobs: ### Windows ### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-tests.yml@self parameters: pool: name: $(DncEngInternalBuildPool) @@ -128,7 +128,7 @@ extends: displayName: 📦 Package dnup dependsOn: [] jobs: - - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-library-package.yml@self parameters: pool: name: $(DncEngInternalBuildPool) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 7b58f5f735c0..2b95f63586cf 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -29,7 +29,7 @@ stages: displayName: 🏰 dnup tests jobs: ############### WINDOWS ############### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-tests.yml@self parameters: pool: name: $(DncEngPublicBuildPool) @@ -39,7 +39,7 @@ stages: helixTargetQueue: windows.amd64.vs2022.pre.open ############### LINUX ############### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-tests.yml@self parameters: pool: name: $(DncEngPublicBuildPool) @@ -49,7 +49,7 @@ stages: helixTargetQueue: ubuntu.2204.amd64.open ############### MACOS ############### - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-tests.yml@self parameters: pool: name: Azure Pipelines @@ -60,7 +60,7 @@ stages: ### ARM64 ### - ${{ if eq(parameters.enableArm64Job, true) }}: - - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + - template: /eng/pipelines/templates/jobs/dnup/dnup-tests.yml@self parameters: pool: name: Azure Pipelines diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup/dnup-library-package.yml similarity index 100% rename from eng/pipelines/templates/jobs/dnup-library-package.yml rename to eng/pipelines/templates/jobs/dnup/dnup-library-package.yml diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup/dnup-tests.yml similarity index 100% rename from eng/pipelines/templates/jobs/dnup-tests.yml rename to eng/pipelines/templates/jobs/dnup/dnup-tests.yml From 423a7d0b9e02f444ba877ecc24ac7133c99f59e5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 13:27:06 -0700 Subject: [PATCH 152/153] Disable SDK CI on dnup/release branch checks if only dnup code changes need to confirm if we change sdk code that things still run for the sdk code. --- .vsts-pr.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.vsts-pr.yml b/.vsts-pr.yml index a0ce56e66843..6ac53bd7ebb9 100644 --- a/.vsts-pr.yml +++ b/.vsts-pr.yml @@ -13,6 +13,11 @@ pr: - documentation/* - README.md - CODEOWNERS + - .vsts-dnup-ci.yml + - .vsts-dnup-pr.yml + - /eng/pipelines/templates/jobs/dnup/* + - src/Installer/* + - test/dnup.Tests/* parameters: - name: enableArm64Job From 9f1a36cd2064d5778d7f95c4c5e38ad9e88f79dd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 28 Oct 2025 14:08:37 -0700 Subject: [PATCH 153/153] add release/dnup --- .vsts-dnup-pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml index 2b95f63586cf..be58e8dfa3a2 100644 --- a/.vsts-dnup-pr.yml +++ b/.vsts-dnup-pr.yml @@ -6,6 +6,7 @@ pr: branches: include: - dnup + - release/dnup paths: include: - src/Installer/dnup/