diff --git a/Directory.Packages.props b/Directory.Packages.props index ab0b8d7a7afa..00af589d9996 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,6 +98,9 @@ + + + @@ -125,6 +128,7 @@ + + diff --git a/sdk.sln b/sdk.sln index 9d90c45efbfd..0b7a84ef7cd8 100644 --- a/sdk.sln +++ b/sdk.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.31903.286 MinimumVisualStudioVersion = 10.0.40219.1 @@ -546,6 +547,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff", "src\Compatibility\ApiDiff\Microsoft.DotNet.ApiDiff\Microsoft.DotNet.ApiDiff.csproj", "{4F23A9C8-945A-A4F4-51E9-FCA215943C0D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DNVM", "DNVM", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + src\DNVM\Channel.cs = src\DNVM\Channel.cs + src\DNVM\dnvm.csproj = src\DNVM\dnvm.csproj + src\DNVM\DnvmEnv.cs = src\DNVM\DnvmEnv.cs + src\DNVM\DotnetReleasesIndex.cs = src\DNVM\DotnetReleasesIndex.cs + src\DNVM\InstallCommand.cs = src\DNVM\InstallCommand.cs + src\DNVM\ListCommand.cs = src\DNVM\ListCommand.cs + src\DNVM\Logger.cs = src\DNVM\Logger.cs + src\DNVM\ManifestUtils.cs = src\DNVM\ManifestUtils.cs + src\DNVM\Program.cs = src\DNVM\Program.cs + src\DNVM\ScalarDeserializer.cs = src\DNVM\ScalarDeserializer.cs + src\DNVM\SelectCommand.cs = src\DNVM\SelectCommand.cs + src\DNVM\UninstallCommand.cs = src\DNVM\UninstallCommand.cs + src\DNVM\UpdateCommand.cs = src\DNVM\UpdateCommand.cs + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1237,6 +1255,7 @@ Global {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A} {A57B724D-D12B-483E-82F2-2183DEA2DFA7} = {AFA55F45-CFCB-9821-A210-2D3496088416} {4F23A9C8-945A-A4F4-51E9-FCA215943C0D} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6} diff --git a/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs new file mode 100644 index 000000000000..0dfabd1b7398 --- /dev/null +++ b/src/Cli/dotnet/Commands/DNVM/DnvmCommandParser.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.CommandLine; +using Microsoft.DotNet.Cli.Extensions; + +namespace Microsoft.DotNet.Cli.Commands.DNVM; + +internal static class DnvmCommandParser +{ + public static readonly string DocsLink = "https://aka.ms/dotnet-dnvm"; + + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + DocumentedCommand command = new("dnvm", DocsLink, "The .NET version manager"); + command.Subcommands.Add(InstallCommandParser.GetCommand()); + command.Subcommands.Add(UninstallCommandParser.GetCommand()); + + command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + + return command; + } +} diff --git a/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs b/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs new file mode 100644 index 000000000000..845f39bf00d7 --- /dev/null +++ b/src/Cli/dotnet/Commands/DNVM/InstallCommand.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System; +using System.CommandLine; +using System.Threading.Tasks; +using Microsoft.DotNet.Cli.Extensions; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.DNVM; +using Semver; + +namespace Microsoft.DotNet.Cli.Commands.DNVM; + +public class InstallCommand +{ + public static int Run(ParseResult parseResult) + { + parseResult.HandleDebugSwitch(); + parseResult.ShowHelpOrErrorIfAppropriate(); + + return RunAsync(parseResult).GetAwaiter().GetResult(); + } + + private static async Task RunAsync(ParseResult parseResult) + { + // Map command line options to DNVM InstallCommand.Options + var sdkVersion = parseResult.GetValue(InstallCommandParser.SdkVersionOption); + var force = parseResult.GetValueForOption(InstallCommandParser.ForceOption); + var sdkDirString = parseResult.GetValueForOption(InstallCommandParser.SdkDirOption); + var verbose = parseResult.GetValueForOption(InstallCommandParser.VerboseOption); + + var env = DnvmEnv.Create(); + var logger = new Logger(verbose ? LogLevel.Info : LogLevel.Standard); + + SdkDirName? sdkDir = null; + if (!string.IsNullOrEmpty(sdkDirString)) + { + sdkDir = new SdkDirName(sdkDirString); + } + + var options = new Microsoft.DotNet.DNVM.InstallCommand.Options + { + SdkVersion = sdkVersion, + Force = force, + SdkDir = sdkDir, + Verbose = verbose + }; + + var result = await Microsoft.DotNet.DNVM.InstallCommand.Run(env, logger, options); + + return (int)result; + } +} \ No newline at end of file diff --git a/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs new file mode 100644 index 000000000000..e4ada5a0f3d8 --- /dev/null +++ b/src/Cli/dotnet/Commands/DNVM/InstallCommandParser.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.CommandLine; +using Semver; +using Microsoft.DotNet.Cli.Extensions; +using Microsoft.DotNet.DNVM; + +namespace Microsoft.DotNet.Cli.Commands.DNVM; + +internal static class InstallCommandParser +{ + public static readonly string DocsLink = "https://aka.ms/dotnet-install"; + + public static readonly Option SdkVersionOption = + new("--sdk-version", "The version of the SDK to install") { IsRequired = true }; + + public static readonly Option ForceOption = + new("--force", "Force installation even if the SDK is already installed"); + + public static readonly Option SdkDirOption = + new("--sdk-dir", "The directory to install the SDK into"); + + public static readonly Option VerboseOption = + new("--verbose", "Enable verbose logging"); + + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + var installCommand = new DocumentedCommand("install", DocsLink) + { + SdkVersionOption, + ForceOption, + SdkDirOption, + VerboseOption + }; + + installCommand.SetAction((parseResult) => InstallCommand.Run(parseResult)); + + return installCommand; + } +} \ No newline at end of file diff --git a/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs b/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs new file mode 100644 index 000000000000..92d7e9e07588 --- /dev/null +++ b/src/Cli/dotnet/Commands/DNVM/UninstallCommand.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.CommandLine; +using System.Threading.Tasks; +using Microsoft.DotNet.Cli.Extensions; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.DNVM; +using Semver; + +namespace Microsoft.DotNet.Cli.Commands.DNVM; + +public class UninstallCommand +{ + public static int Run(ParseResult parseResult) + { + parseResult.HandleDebugSwitch(); + parseResult.ShowHelpOrErrorIfAppropriate(); + + return RunAsync(parseResult).GetAwaiter().GetResult(); + } + + private static async Task RunAsync(ParseResult parseResult) + { + var sdkVersion = parseResult.GetValue(UninstallCommandParser.SdkVersionArgument); + var sdkDirString = parseResult.GetValueForOption(UninstallCommandParser.SdkDirOption); + + var env = DnvmEnv.Create(); + var logger = new Logger(LogLevel.Standard); + + // Convert sdk-dir option from string to SdkDirName if provided + SdkDirName? sdkDir = null; + if (!string.IsNullOrEmpty(sdkDirString)) + { + sdkDir = new SdkDirName(sdkDirString); + } + + return await Microsoft.DotNet.DNVM.UninstallCommand.Run(env, logger, sdkVersion, sdkDir); + } +} diff --git a/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs b/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs new file mode 100644 index 000000000000..da7b599b8b9d --- /dev/null +++ b/src/Cli/dotnet/Commands/DNVM/UninstallCommandParser.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.CommandLine; +using Semver; +using Microsoft.DotNet.Cli.Extensions; +using Microsoft.DotNet.DNVM; + +namespace Microsoft.DotNet.Cli.Commands.DNVM; + +internal static class UninstallCommandParser +{ + public static readonly string DocsLink = "https://aka.ms/dotnet-dnvm"; + + public static readonly Argument SdkVersionArgument = + new("sdk-version", "The version of the SDK to uninstall"); + + public static readonly Option SdkDirOption = + new("--sdk-dir", "Uninstall the SDK from the given directory"); + + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + var uninstallCommand = new DocumentedCommand("uninstall", DocsLink) + { + SdkVersionArgument, + SdkDirOption + }; + + uninstallCommand.SetAction((parseResult) => UninstallCommand.Run(parseResult)); + + return uninstallCommand; + } +} diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs index 3ce2e132ad25..d653f79702f8 100644 --- a/src/Cli/dotnet/Parser.cs +++ b/src/Cli/dotnet/Parser.cs @@ -41,6 +41,7 @@ using Microsoft.DotNet.Cli.Commands.VSTest; using Microsoft.DotNet.Cli.Commands.Workload; using Microsoft.DotNet.Cli.Commands.Workload.Search; +using Microsoft.DotNet.Cli.Commands.DNVM; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Cli.Utils.Extensions; @@ -89,6 +90,7 @@ public static class Parser VSTestCommandParser.GetCommand(), HelpCommandParser.GetCommand(), SdkCommandParser.GetCommand(), + DnvmCommandParser.GetCommand(), InstallSuccessCommand, WorkloadCommandParser.GetCommand(), new System.CommandLine.StaticCompletions.CompletionsCommand() diff --git a/src/Cli/dotnet/dotnet.csproj b/src/Cli/dotnet/dotnet.csproj index f6b9b0a7015d..4ddea2108093 100644 --- a/src/Cli/dotnet/dotnet.csproj +++ b/src/Cli/dotnet/dotnet.csproj @@ -45,6 +45,7 @@ + diff --git a/src/DNVM/Channel.cs b/src/DNVM/Channel.cs new file mode 100644 index 000000000000..d03ec907e979 --- /dev/null +++ b/src/DNVM/Channel.cs @@ -0,0 +1,146 @@ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using Semver; + +namespace Microsoft.DotNet.DNVM; + +public static class Channels +{ + public static string GetDesc(this Channel c) => c switch + { + Channel.VersionedMajorMinor v => $"The latest version in the {v} support channel", + Channel.VersionedFeature v => $"The latest version in the {v} support channel", + Channel.Lts => "The latest version in Long-Term support", + Channel.Sts => "The latest version in Short-Term support", + Channel.Latest => "The latest supported version from either the LTS or STS support channels.", + Channel.Preview => "The latest preview version", + }; +} + + +public abstract partial record Channel +{ + private Channel() { } + + /// + /// A major-minor versioned channel. + /// + public sealed partial record VersionedMajorMinor(int Major, int Minor) : Channel; + /// + /// A major-minor-patch versioned channel. + /// + /// The feature level of the version, e.g. 1 in 9.0.100 + public sealed partial record VersionedFeature(int Major, int Minor, int FeatureLevel) : Channel; + /// + /// Newest Long Term Support release. + /// + public sealed partial record Lts : Channel; + /// + /// Newest Short Term Support release. + /// + public sealed partial record Sts : Channel; + /// + /// Latest supported version from either the LTS or STS support channels. + /// + public sealed partial record Latest : Channel; + /// + /// Latest preview version. + /// + public sealed partial record Preview : Channel; +} + +partial record Channel : ISerializeProvider +{ + public abstract string GetDisplayName(); + public sealed override string ToString() => GetDisplayName(); + public string GetLowerName() => GetDisplayName().ToLowerInvariant(); + + static ISerialize ISerializeProvider.Instance => Serialize.Instance; + + private sealed class Serialize : ISerialize + { + public static readonly Serialize Instance = new(); + private Serialize() { } + + /// + /// Serialize as a string. + /// + void ISerialize.Serialize(Channel channel, ISerializer serializer) + => serializer.WriteString(channel.GetLowerName()); + } + + partial record VersionedMajorMinor + { + public override string GetDisplayName() => $"{Major}.{Minor}"; + } + partial record VersionedFeature + { + public override string GetDisplayName() => $"{Major}.{Minor}.{FeatureLevel}xx"; + } + partial record Lts : Channel + { + public override string GetDisplayName() => "LTS"; + } + partial record Sts : Channel + { + public override string GetDisplayName() => "STS"; + } + partial record Latest : Channel + { + public override string GetDisplayName() => "Latest"; + } + partial record Preview : Channel + { + public override string GetDisplayName() => "Preview"; + } +} + +partial record Channel : IDeserializeProvider +{ + static IDeserialize IDeserializeProvider.Instance => DeserializeProxy.Instance; + + public static Channel FromString(string str) + { + switch (str) + { + case "lts": return new Lts(); + case "sts": return new Sts(); + case "latest": return new Latest(); + case "preview": return new Preview(); + default: + var components = str.Split('.'); + switch (components) + { + case [_, _]: + var major = int.Parse(components[0]); + var minor = int.Parse(components[1]); + return new VersionedMajorMinor(major, minor); + case [_, _, _]: + if (components[2] is not [<= '9' and >= '0', 'x', 'x']) + { + throw new DeserializeException($"Feature band must be 3 characters and end in 'xx': {str}"); + } + major = int.Parse(components[0]); + minor = int.Parse(components[1]); + var featureLevel = components[2][0] - '0'; + return new VersionedFeature(major, minor, featureLevel); + default: + throw new DeserializeException($"Invalid channel version: {str}"); + } + } + } + + private sealed class DeserializeProxy : IDeserialize + { + public static readonly DeserializeProxy Instance = new(); + + /// + /// Deserialize as a string. + /// + public Channel Deserialize(IDeserializer deserializer) + => FromString(StringProxy.Instance.Deserialize(deserializer)); + } +} diff --git a/src/DNVM/CommandLineArguments.cs b/src/DNVM/CommandLineArguments.cs new file mode 100644 index 000000000000..e37b73c42cbb --- /dev/null +++ b/src/DNVM/CommandLineArguments.cs @@ -0,0 +1,238 @@ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.Linq; +using Semver; +using Serde; +using Serde.CmdLine; +using Spectre.Console; +using StaticCs; + +namespace Microsoft.DotNet.DNVM; + +[GenerateDeserialize] +[Command("dnvm", Summary = "Install and manage .NET SDKs.")] +public partial record DnvmArgs +{ + [CommandOption("--enable-dnvm-previews", Description = "Enable dnvm previews.")] + public bool? EnableDnvmPreviews { get; init; } + + [CommandGroup("command")] + public DnvmSubCommand? SubCommand { get; init; } +} + +[Closed] +[GenerateDeserialize] +public abstract partial record DnvmSubCommand +{ + private DnvmSubCommand() { } + + [Command("install", Summary = "Install an SDK.")] + public sealed partial record InstallArgs : DnvmSubCommand + { + [CommandParameter(0, "version", Description = "The version of the SDK to install.")] + [SerdeMemberOptions(DeserializeProxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + + [CommandOption("-f|--force", Description = "Force install the given SDK, even if already installed")] + public bool? Force { get; init; } = null; + + [CommandOption("-s|--sdk-dir", Description = "Install the SDK into a separate directory with the given name.")] + [SerdeMemberOptions(DeserializeProxy = typeof(NullableRefProxy.De))] // Treat as string + public SdkDirName? SdkDir { get; init; } = null; + + [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")] + public bool? Verbose { get; init; } = null; + } + + [Command("track", Summary = "Start tracking a new channel.")] + public sealed partial record TrackArgs : DnvmSubCommand + { + [CommandParameter(0, "channel", Description = "Track the channel specified.")] + [SerdeMemberOptions(DeserializeProxy = typeof(CaseInsensitiveChannel))] + public required Channel Channel { get; init; } + + /// + /// URL to the dotnet feed containing the releases index and SDKs. + /// + [CommandOption("--feed-url", Description = "Set the feed URL to download the SDK from.")] + public string? FeedUrl { get; init; } + + [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")] + public bool? Verbose { get; init; } = null; + + [CommandOption("-f|--force", Description = "Force tracking the given channel, even if already tracked.")] + public bool? Force { get; init; } = null; + + /// + /// Answer yes to every question or use the defaults. + /// + [CommandOption("-y", Description = "Answer yes to all prompts.")] + public bool? Yes { get; init; } = null; + + [CommandOption("--prereqs", Description = "Print prereqs for dotnet on Ubuntu.")] + public bool? Prereqs { get; init; } = null; + + /// + /// When specified, install the SDK into a separate directory with the given name, + /// translated to lower-case. Preview releases are installed into a directory named 'preview' + /// by default. + /// + [CommandOption("-s|--sdk-dir", Description = "Track the channel in a separate directory with the given name.")] + public string? SdkDir { get; init; } = null; + } + + [Command("update", Summary = "Update the installed SDKs.")] + public sealed partial record UpdateArgs : DnvmSubCommand + { + [CommandOption("--feed-url", Description = "Set the feed URL to download the SDK from.")] + public string? FeedUrl { get; init; } = null; + + [CommandOption("-v|--verbose", Description = "Print debugging messages to the console.")] + public bool? Verbose { get; init; } = null; + + [CommandOption("--self", Description = "Update dnvm itself in the current location.")] + public bool? Self { get; init; } = null; + + [CommandOption("-y", Description = "Answer yes to all prompts.")] + public bool? Yes { get; init; } = null; + } + + [Command("list", Summary = "List installed SDKs.")] + public sealed partial record ListArgs : DnvmSubCommand + { + } + + [Command("select", Summary = "Select the active SDK directory.", Description = +"Select the active SDK directory, meaning the directory that will be used when running `dotnet` " + +"commands. This is the same directory passed to the `-s` option for `dnvm install`.\n" + +"\n" + +"Note: This command does not change between SDK versions installed in the same directory. For " + +"that, use the built-in dotnet global.json file. Information about global.json can be found at " + +"https://learn.microsoft.com/en-us/dotnet/core/tools/global-json.")] + public sealed partial record SelectArgs : DnvmSubCommand + { + [CommandParameter(0, "sdkDirName", Description = "The name of the SDK directory to select.")] + public required string SdkDirName { get; init; } + } + + [Command("untrack", Summary = "Remove a channel from the list of tracked channels.")] + public sealed partial record UntrackArgs : DnvmSubCommand + { + [CommandParameter(0, "channel", Description = "The channel to untrack.")] + [SerdeMemberOptions(DeserializeProxy = typeof(CaseInsensitiveChannel))] + public required Channel Channel { get; init; } + } + + [Command("uninstall", Summary = "Uninstall an SDK.")] + public sealed partial record UninstallArgs : DnvmSubCommand + { + [CommandParameter(0, "sdkVersion", Description = "The version of the SDK to uninstall.")] + [SerdeMemberOptions(DeserializeProxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + + [CommandOption("-s|--sdk-dir", Description = "Uninstall the SDK from the given directory.")] + [SerdeMemberOptions(DeserializeProxy = typeof(NullableRefProxy.De))] // Treat as string + public SdkDirName? SdkDir { get; init; } = null; + } + + [Command("prune", Summary = "Remove all SDKs with older patch versions.")] + public sealed partial record PruneArgs : DnvmSubCommand + { + [CommandOption("-v|--verbose", Description = "Print extra debugging info to the console.")] + public bool? Verbose { get; init; } = null; + + [CommandOption("--dry-run", Description = "Print the list of the SDKs to be uninstalled, but don't uninstall.")] + public bool? DryRun { get; init; } = null; + } + + [Command("restore", Summary = "Restore the SDK listed in the global.json file.", + Description = "Downloads the SDK in the global.json in or above the current directory.")] + public sealed partial record RestoreArgs : DnvmSubCommand + { + [CommandOption("-l|--local", Description = "Install the sdk into the .dotnet folder in the same directory as global.json.")] + public bool? Local { get; init; } = null; + + [CommandOption("-f|--force", Description = "Force install the SDK, even if already installed.")] + public bool? Force { get; init; } = null; + + [CommandOption("-v|--verbose", Description = "Print extra debugging info to the console.")] + public bool? Verbose { get; init; } = null; + } + + /// + /// Deserialize a named channel case-insensitively. Produces a user-friendly error message if the + /// channel is not recognized. + /// + private sealed class CaseInsensitiveChannel : IDeserializeProvider, IDeserialize + { + public ISerdeInfo SerdeInfo => StringProxy.SerdeInfo; + static IDeserialize IDeserializeProvider.Instance { get; } = new CaseInsensitiveChannel(); + private CaseInsensitiveChannel() { } + + public Channel Deserialize(IDeserializer deserializer) + { + try + { + return Channel.FromString(StringProxy.Instance.Deserialize(deserializer).ToLowerInvariant()); + } + catch (DeserializeException) + { + var sep = Environment.NewLine + "\t- "; + IEnumerable channels = [new Channel.Latest(), new Channel.Preview(), new Channel.Lts(), new Channel.Sts()]; + throw new DeserializeException( + "Channel must be one of:" + + sep + string.Join(sep, channels)); + } + } + } +} + +public static class CommandLineArguments +{ + /// + /// Returns null if an error was produced or help was requested. + /// + public static DnvmArgs? TryParse(IAnsiConsole console, string[] args) + { + try + { + return ParseRaw(console, args); + } + catch (ArgumentSyntaxException ex) + { + console.WriteLine("error: " + ex.Message); + console.WriteLine(CmdLine.GetHelpText(includeHelp: true)); + return null; + } + } + + /// + /// Throws an exception if the command was not recognized. Returns null if help was requested. + /// + public static DnvmArgs? ParseRaw(IAnsiConsole console, string[] args) + { + var result = CmdLine.ParseRawWithHelp(args); + DnvmArgs dnvmCmd; + switch (result) + { + case CmdLine.ParsedArgsOrHelpInfos.Parsed(var value): + dnvmCmd = value; + if (dnvmCmd.EnableDnvmPreviews is null && dnvmCmd.SubCommand is null) + { + // Empty command is a help request. + console.WriteLine(CmdLine.GetHelpText(includeHelp: true)); + } + return dnvmCmd; + case CmdLine.ParsedArgsOrHelpInfos.Help(var helpInfos): + var rootInfo = SerdeInfoProvider.GetDeserializeInfo(); + var lastInfo = helpInfos.Last(); + console.WriteLine(CmdLine.GetHelpText(rootInfo, lastInfo, includeHelp: true)); + return null; + default: + throw new InvalidOperationException(); + } + } +} diff --git a/src/DNVM/DnvmEnv.cs b/src/DNVM/DnvmEnv.cs new file mode 100644 index 000000000000..f402f31d7836 --- /dev/null +++ b/src/DNVM/DnvmEnv.cs @@ -0,0 +1,148 @@ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Zio; +using Zio.FileSystems; +using static System.Environment; + +namespace Microsoft.DotNet.DNVM; + +/// +/// Represents the environment of a dnvm process. +/// +public sealed partial class DnvmEnv +{ + public bool IsPhysicalDnvmHome { get; } + public readonly IFileSystem DnvmHomeFs; + public readonly IFileSystem CwdFs; + public readonly UPath Cwd; + public string RealPath(UPath path) => DnvmHomeFs.ConvertPathToInternal(path); + public SubFileSystem TempFs { get; } + public Func GetUserEnvVar { get; } + public Action SetUserEnvVar { get; } + public IEnumerable DotnetFeedUrls { get; } + public string UserHome { get; } + public ScopedHttpClient HttpClient { get; } + + public DnvmEnv( + string userHome, + IFileSystem homeFs, + IFileSystem cwdFs, + UPath cwd, + bool isPhysical, + Func getUserEnvVar, + Action setUserEnvVar, + IEnumerable? dotnetFeedUrls = null, + HttpClient? httpClient = null) + { + UserHome = userHome; + DnvmHomeFs = homeFs; + CwdFs = cwdFs; + Cwd = cwd; + IsPhysicalDnvmHome = isPhysical; + // TempFs must be a physical file system because we pass the path to external + // commands that will not be able to write to shared memory + TempFs = new SubFileSystem( + PhysicalFs, + PhysicalFs.ConvertPathFromInternal(Path.GetTempPath()), + owned: false); + GetUserEnvVar = getUserEnvVar; + SetUserEnvVar = setUserEnvVar; + DotnetFeedUrls = dotnetFeedUrls ?? DefaultDotnetFeedUrls; + HttpClient = new ScopedHttpClient(httpClient ?? new HttpClient() { + Timeout = Timeout.InfiniteTimeSpan + }); + } +} + +public sealed partial class DnvmEnv : IDisposable +{ + public const string ManifestFileName = "dnvmManifest.json"; + public static EqArray DefaultDotnetFeedUrls { get;} = [ + "https://builds.dotnet.microsoft.com/dotnet", + "https://dotnetcli.blob.core.windows.net/dotnet", + ]; + public static UPath ManifestPath => UPath.Root / ManifestFileName; + public static UPath EnvPath => UPath.Root / "env"; + public static UPath DnvmExePath => UPath.Root / Utilities.DnvmExeName; + public static UPath SymlinkPath => UPath.Root / Utilities.DotnetExeName; + public static UPath GetSdkPath(SdkDirName sdkDirName) => UPath.Root / sdkDirName.Name; + /// + /// Default DNVM_HOME is + /// ~/.local/share/dnvm on Linux + /// %LocalAppData%/dnvm on Windows + /// ~/Library/Application Support/dnvm on Mac + /// + public static readonly string DefaultDnvmHome = Path.Combine( + GetFolderPath(SpecialFolder.LocalApplicationData, SpecialFolderOption.DoNotVerify), + "dnvm"); + /// + /// The location of the SDK install directory, relative to + /// + public static readonly SdkDirName DefaultSdkDirName = new("dn"); + public static readonly PhysicalFileSystem PhysicalFs = new(); + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// + /// Get the path to DNVM_HOME, which is the location of the dnvm manifest + /// and the installed SDKs. If the environment variable is not set, uses + /// as the default. + /// + public static DnvmEnv CreateDefault( + string? home = null, + string? dotnetFeedUrl = null) + { + home ??= Environment.GetEnvironmentVariable("DNVM_HOME"); + var dnvmHome = string.IsNullOrWhiteSpace(home) + ? DefaultDnvmHome + : home; + return CreatePhysical(dnvmHome, + n => Environment.GetEnvironmentVariable(n, EnvironmentVariableTarget.User), + (n, v) => Environment.SetEnvironmentVariable(n, v, EnvironmentVariableTarget.User), + dotnetFeedUrl); + } + + public static DnvmEnv CreatePhysical( + string realPath, + Func getUserEnvVar, + Action setUserEnvVar, + string? dotnetFeedUrl = null) + { + Directory.CreateDirectory(realPath); + + return new DnvmEnv( + userHome: GetFolderPath(SpecialFolder.UserProfile, SpecialFolderOption.DoNotVerify), + new SubFileSystem(PhysicalFs, PhysicalFs.ConvertPathFromInternal(realPath)), + PhysicalFs, + PhysicalFs.ConvertPathFromInternal(Environment.CurrentDirectory), + isPhysical: true, + getUserEnvVar, + setUserEnvVar, + dotnetFeedUrls: dotnetFeedUrl is not null ? [ dotnetFeedUrl ] : null); + } + + /// + /// Reads a manifest (any version) from the given path and returns an up-to-date (latest version). Throws if the manifest is invalid. + /// + public async Task ReadManifest() + { + var text = DnvmHomeFs.ReadAllText(ManifestPath); + return await ManifestUtils.DeserializeNewOrOldManifest(HttpClient, text, DotnetFeedUrls); + } + + public void WriteManifest(Manifest manifest) + { + var text = JsonSerializer.Serialize(manifest); + DnvmHomeFs.WriteAllText(ManifestPath, text, Encoding.UTF8); + } + + public void Dispose() + { } +} diff --git a/src/DNVM/DnvmReleases.cs b/src/DNVM/DnvmReleases.cs new file mode 100644 index 000000000000..7f84bb7febc9 --- /dev/null +++ b/src/DNVM/DnvmReleases.cs @@ -0,0 +1,17 @@ + +using System.Collections.Generic; +using Serde; +using static Dnvm.DnvmReleases; + +namespace Dnvm; + +[GenerateSerde] +public partial record DnvmReleases(Release LatestVersion) +{ + public Release? LatestPreview { get; init; } + + [GenerateSerde] + public partial record Release( + string Version, + Dictionary Artifacts); +} diff --git a/src/DNVM/DotnetReleasesIndex.cs b/src/DNVM/DotnetReleasesIndex.cs new file mode 100644 index 000000000000..c662733b372b --- /dev/null +++ b/src/DNVM/DotnetReleasesIndex.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading.Tasks; +using System.Xml.Schema; +using Semver; + +namespace Microsoft.DotNet.DNVM; + +public partial record DotnetReleasesIndex +{ + public static readonly DotnetReleasesIndex Empty = new() { ChannelIndices = [ ] }; + + public required ImmutableArray ChannelIndices { get; init; } + + public partial record ChannelIndex + { + /// + /// The major and minor version of the release, e.g. '42.42'. + /// + public required string MajorMinorVersion { get; init; } + /// + /// The version number of the latest SDK, e.g. '42.42.104'. + /// + public required string LatestSdk { get; init; } + /// + /// The version number of the release, e.g. '42.42.4'. + /// + public required string LatestRelease { get; init; } + /// + /// Whether this version is in an LTS or STS cadence. + /// + public required string ReleaseType { get; init; } + /// + /// The support phase the release is in, e.g. 'active' or 'eol'. + /// + public required string SupportPhase { get; init; } + /// + /// The URL to the releases index for this channel. + /// + public required string ChannelReleaseIndexUrl { get; init; } + } +} + +public partial record ChannelReleaseIndex +{ + public static readonly ChannelReleaseIndex Empty = new() { Releases = [ ] }; + + public ChannelReleaseIndex AddRelease(Release release) => this with { + Releases = Releases.Add(release) + }; + + public required EqArray Releases { get; init; } + + public partial record Release() + { + public required SemVersion ReleaseVersion { get; init; } + public required Component Runtime { get; init; } + public required Component Sdk { get; init; } + public required EqArray Sdks { get; init; } + public required Component AspNetCore { get; init; } + public required Component WindowsDesktop { get; init; } + } + + public partial record Component + { + public required SemVersion Version { get; init; } + + public required EqArray Files { get; init; } + } + + public partial record File + { + public required string Name { get; init; } + public required string Url { get; init; } + public required string Rid { get; init; } + public string? Hash { get; init; } = null; + } +} + +public partial record DotnetReleasesIndex +{ + public const string ReleasesUrlSuffix = "/release-metadata/releases-index.json"; + public async static Task FetchLatestIndex( + ScopedHttpClient client, + IEnumerable feeds, + string urlSuffix = ReleasesUrlSuffix) + { + ScopedHttpResponseMessage? lastResponse = null; + foreach (var feed in feeds) + { + var adjustedUrl = feed.TrimEnd('/') + urlSuffix; + var response = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout, + _ => client.GetAsync(adjustedUrl) + ); + if (response.IsSuccessStatusCode) + { + return JsonSerializer.Deserialize( + await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout, + async _ => await response.Content.ReadAsStringAsync() + ) + ); + } + else + { + lastResponse = response; + } + } + lastResponse!.EnsureSuccessStatusCode(); + throw ExceptionUtilities.Unreachable; + } + + public ChannelIndex? GetChannelIndex(Channel c) + { + (ChannelIndex Release, SemVersion Version)? latestRelease = null; + foreach (var release in this.ChannelIndices) + { + var supportPhase = release.SupportPhase.ToLowerInvariant(); + var releaseType = release.ReleaseType.ToLowerInvariant(); + if (!SemVersion.TryParse(release.LatestRelease, SemVersionStyles.Strict, out var releaseVersion)) + { + continue; + } + var found = (c, supportPhase, releaseType) switch + { + (Channel.Latest, "active", _) + or (Channel.Lts, "active", "lts") + or (Channel.Sts, "active", "sts") + or (Channel.Preview, "go-live", _) + or (Channel.Preview, "preview", _) => true, + (Channel.VersionedMajorMinor v, _, _) when v.ToString() == releaseVersion.ToMajorMinor() => true, + (Channel.VersionedFeature v, _, _) when v.ToString() == releaseVersion.ToFeature() => true, + _ => false + }; + if (found && + (latestRelease is not { } latest || + SemVersion.ComparePrecedence(releaseVersion, latest.Version) > 0)) + { + latestRelease = (release, releaseVersion); + } + } + return latestRelease?.Release; + } +} diff --git a/src/DNVM/InstallCommand.cs b/src/DNVM/InstallCommand.cs new file mode 100644 index 000000000000..f5459cb8c422 --- /dev/null +++ b/src/DNVM/InstallCommand.cs @@ -0,0 +1,336 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Semver; + +using Spectre.Console; +using Zio; + +namespace Microsoft.DotNet.DNVM; + +public static partial class InstallCommand +{ + public enum Result + { + Success = 0, + CouldntFetchReleaseIndex, + UnknownChannel, + ManifestFileCorrupted, + ManifestIOError, + InstallError + } + + public sealed record Options + { + public required SemVersion SdkVersion { get; init; } + public bool Force { get; init; } = false; + public SdkDirName? SdkDir { get; init; } = null; + public bool Verbose { get; init; } = false; + } + + public static Task Run(DnvmEnv env, Logger logger, DnvmSubCommand.InstallArgs args) + { + return Run(env, logger, new Options + { + SdkVersion = args.SdkVersion, + Force = args.Force ?? false, + SdkDir = args.SdkDir, + Verbose = args.Verbose ?? false, + }); + } + + public static async Task Run(DnvmEnv env, Logger logger, Options options) + { + var sdkDir = options.SdkDir ?? DnvmEnv.DefaultSdkDirName; + + Manifest manifest; + try + { + manifest = await ManifestUtils.ReadOrCreateManifest(env); + } + catch (InvalidDataException) + { + logger.Error("Manifest file corrupted"); + return Result.ManifestFileCorrupted; + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.Error("Error reading manifest file: " + e.Message); + return Result.ManifestIOError; + } + + var sdkVersion = options.SdkVersion; + var channel = new Channel.VersionedMajorMinor(sdkVersion.Major, sdkVersion.Minor); + + if (!options.Force && ManifestUtils.IsSdkInstalled(manifest, sdkVersion, sdkDir)) + { + logger.Log($"Version {sdkVersion} is already installed in directory '{sdkDir.Name}'." + + " Skipping installation. To install anyway, pass --force."); + return Result.Success; + } + + DotnetReleasesIndex versionIndex; + try + { + versionIndex = await DotnetReleasesIndex.FetchLatestIndex(env.HttpClient, env.DotnetFeedUrls); + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.Error($"Could not fetch the releases index: {e.Message}"); + return Result.CouldntFetchReleaseIndex; + } + + var result = await TryGetReleaseFromIndex(env.HttpClient, versionIndex, channel, sdkVersion) + ?? await TryGetReleaseFromServer(env, sdkVersion); + if (result is not ({ } sdkComponent, { } release)) + { + logger.Error($"SDK version '{sdkVersion}' could not be found in .NET releases index or server."); + return Result.UnknownChannel; + } + + var installError = await InstallSdk(env, manifest, sdkComponent, release, sdkDir, logger); + if (installError is not Result.Ok) + { + return Result.InstallError; + } + + return Result.Success; + } + + internal static async Task<(ChannelReleaseIndex.Component, ChannelReleaseIndex.Release)?> TryGetReleaseFromIndex( + ScopedHttpClient httpClient, + DotnetReleasesIndex versionIndex, + Channel channel, + SemVersion sdkVersion) + { + if (versionIndex.GetChannelIndex(channel) is { } channelIndex) + { + var channelReleaseIndexText = await httpClient.GetStringAsync(channelIndex.ChannelReleaseIndexUrl); + var releaseIndex = JsonSerializer.Deserialize(channelReleaseIndexText); + var result = + from r in releaseIndex.Releases + from sdk in r.Sdks + where sdk.Version == sdkVersion + select (sdk, r); + + return result.FirstOrDefault(); + } + + return null; + } + + private static async Task<(ChannelReleaseIndex.Component, ChannelReleaseIndex.Release)?> TryGetReleaseFromServer( + DnvmEnv env, + SemVersion sdkVersion) + { + foreach (var feedUrl in env.DotnetFeedUrls) + { + var downloadUrl = $"/Sdk/{sdkVersion}/productCommit-{Utilities.CurrentRID}.json"; + try + { + var productCommitData = JsonSerializer.Deserialize(await env.HttpClient.GetStringAsync(feedUrl.TrimEnd('/') + downloadUrl)); + if (productCommitData.Installer.Version != sdkVersion) + { + throw new InvalidOperationException("Fetched product commit data does not match requested SDK version"); + } + var archiveName = ConstructArchiveName(versionString: sdkVersion.ToString(), Utilities.CurrentRID, Utilities.ZipSuffix); + var sdk = new ChannelReleaseIndex.Component + { + Version = sdkVersion, + Files = [ new ChannelReleaseIndex.File + { + Name = archiveName, + Url = MakeSdkUrl(feedUrl, sdkVersion, archiveName), + Rid = Utilities.CurrentRID.ToString(), + }] + }; + + var release = new ChannelReleaseIndex.Release + { + Sdk = sdk, + AspNetCore = new ChannelReleaseIndex.Component + { + Version = productCommitData.Aspnetcore.Version, + Files = [] + }, + Runtime = new() + { + Version = productCommitData.Runtime.Version, + Files = [] + }, + ReleaseVersion = productCommitData.Runtime.Version, + Sdks = [sdk], + WindowsDesktop = new() + { + Version = productCommitData.Windowsdesktop.Version, + Files = [] + }, + }; + return (sdk, release); + } + catch + { + continue; + } + } + return null; + } + + private static string MakeSdkUrl(string feedUrl, SemVersion version, string archiveName) + { + return feedUrl.TrimEnd('/') + $"/Sdk/{version}/{archiveName}"; + } + + [GenerateDeserialize] + private partial record CommitData + { + public required Component Installer { get; init; } + public required Component Sdk { get; init; } + public required Component Aspnetcore { get; init; } + public required Component Runtime { get; init; } + public required Component Windowsdesktop { get; init; } + + [GenerateDeserialize] + public partial record Component + { + public required SemVersion Version { get; init; } + } + } + + [Closed] + internal enum InstallError + { + DownloadFailed, + ExtractFailed + } + + /// + /// Install the given SDK inside the given directory, and update the manifest. Does not update the channel manifest. + /// + /// Throws when manifest already contains the given SDK. + internal static async Task> InstallSdk( + DnvmEnv env, + Manifest manifest, + ChannelReleaseIndex.Component sdkComponent, + ChannelReleaseIndex.Release release, + SdkDirName sdkDir, + Logger logger) + { + var sdkVersion = sdkComponent.Version; + + var ridString = Utilities.CurrentRID.ToString(); + var sdkInstallPath = UPath.Root / sdkDir.Name; + var downloadFile = sdkComponent.Files.Single(f => f.Rid == ridString && f.Url.EndsWith(Utilities.ZipSuffix)); + var link = downloadFile.Url; + logger.Info("Download link: " + link); + + logger.Log($"Downloading SDK {sdkVersion} for {ridString}"); + var curMuxerVersion = manifest.MuxerVersion(sdkDir); + var err = await InstallSdkToDir(curMuxerVersion, release.Runtime.Version, env.HttpClient, link, env.DnvmHomeFs, sdkInstallPath, env.TempFs, logger); + if (err is not null) + { + return err; + } + + SelectCommand.SelectDir(logger, env, manifest.CurrentSdkDir, sdkDir); + + var result = JsonSerializer.Serialize(manifest); + logger.Info("Existing manifest: " + result); + + if (!ManifestUtils.IsSdkInstalled(manifest, sdkVersion, sdkDir)) + { + manifest = manifest with + { + InstalledSdks = manifest.InstalledSdks.Add(new InstalledSdk + { + ReleaseVersion = release.ReleaseVersion, + RuntimeVersion = release.Runtime.Version, + AspNetVersion = release.AspNetCore.Version, + SdkVersion = sdkVersion, + SdkDirName = sdkDir, + }) + }; + + env.WriteManifest(manifest); + } + + return manifest; + } + + internal static async Task InstallSdkToDir( + SemVersion? curMuxerVersion, + SemVersion runtimeVersion, + ScopedHttpClient httpClient, + string downloadUrl, + IFileSystem destFs, + UPath destPath, + IFileSystem tempFs, + Logger logger) + { + // The Release name does not contain a version + string archiveName = ConstructArchiveName(versionString: null, Utilities.CurrentRID, Utilities.ZipSuffix); + + // Download and extract into a temp directory + using var tempDir = new DirectoryResource(Directory.CreateTempSubdirectory().FullName); + string archivePath = Path.Combine(tempDir.Path, archiveName); + logger.Info("Archive path: " + archivePath); + + var downloadError = await logger.DownloadWithProgress( + httpClient, + archivePath, + downloadUrl, + "Downloading SDK"); + + if (downloadError is not null) + { + logger.Error(downloadError); + return InstallError.DownloadFailed; + } + + logger.Log($"Installing to {destPath}"); + string? extractResult = await Utilities.ExtractSdkToDir( + curMuxerVersion, + runtimeVersion, + archivePath, + tempFs, + destFs, + destPath); + File.Delete(archivePath); + if (extractResult != null) + { + logger.Error("Extract failed: " + extractResult); + return InstallError.ExtractFailed; + } + + var dotnetExePath = destPath / Utilities.DotnetExeName; + if (!OperatingSystem.IsWindows()) + { + logger.Info("chmoding downloaded host"); + try + { + Utilities.ChmodExec(destFs, dotnetExePath); + } + catch (Exception e) + { + logger.Error("chmod failed: " + e.Message); + return InstallError.ExtractFailed; + } + } + + return null; + } + + internal static string ConstructArchiveName( + string? versionString, + RID rid, + string suffix) + { + return versionString is null + ? $"dotnet-sdk-{rid}{suffix}" + : $"dotnet-sdk-{versionString}-{rid}{suffix}"; + } +} diff --git a/src/DNVM/ListCommand.cs b/src/DNVM/ListCommand.cs new file mode 100644 index 000000000000..74592379d656 --- /dev/null +++ b/src/DNVM/ListCommand.cs @@ -0,0 +1,62 @@ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Spectre.Console; +using Zio; + +namespace Microsoft.DotNet.DNVM; + +public static class ListCommand +{ + /// + /// Prints a list of installed SDK versions and their locations. + public static async Task Run(Logger logger, DnvmEnv home) + { + Manifest manifest; + try + { + manifest = await home.ReadManifest(); + } + catch (Exception e) + { + Environment.FailFast("Error reading manifest: ", e); + // unreachable + return 1; + } + + PrintSdks(logger, manifest, home.RealPath(UPath.Root)); + + return 0; + } + + public static void PrintSdks(Logger logger, Manifest manifest, string homePath) + { + logger.Log($"DNVM_HOME: {homePath}"); + logger.Log(); + logger.Log("Installed SDKs:"); + logger.Log(); + var table = new Table(); + table.AddColumn(new TableColumn(" ")); + table.AddColumn("Version"); + table.AddColumn("Channel"); + table.AddColumn("Location"); + foreach (var sdk in manifest.InstalledSdks) + { + string selected = manifest.CurrentSdkDir == sdk.SdkDirName ? "*" : " "; + var channels = manifest.RegisteredChannels + .Where(c => c.InstalledSdkVersions.Contains(sdk.SdkVersion)) + .Select(c => c.ChannelName.GetLowerName()); + table.AddRow(selected, sdk.SdkVersion.ToString(), string.Join(", ", channels), sdk.SdkDirName.Name); + } + logger.Console.Write(table); + + logger.Log(); + logger.Log("Tracked channels:"); + logger.Log(); + foreach (var c in manifest.TrackedChannels()) + { + logger.Log($" • {c.ChannelName.GetLowerName()}"); + } + } +} diff --git a/src/DNVM/Logger.cs b/src/DNVM/Logger.cs new file mode 100644 index 000000000000..6234b930a5b0 --- /dev/null +++ b/src/DNVM/Logger.cs @@ -0,0 +1,58 @@ +using System; +using Spectre.Console; + +namespace Microsoft.DotNet.DNVM; + +public enum LogLevel +{ + Error = 1, + Warn, + Normal, + Info, +} + +public sealed record Logger(IAnsiConsole Console) +{ + // Mutable for now, should be immutable once the command line parser supports global options + public LogLevel LogLevel = LogLevel.Normal; + + public void Error(string msg) + { + if (LogLevel >= LogLevel.Error) + { + Console.MarkupLineInterpolated($"{Environment.NewLine}[default on red]Error[/]: {msg}{Environment.NewLine}"); + } + } + + public void Info(string msg) + { + if (LogLevel >= LogLevel.Info) + { + Console.WriteLine($"Info({DateTime.UtcNow.TimeOfDay}): {msg}"); + } + } + + public void Warn(string msg) + { + if (LogLevel >= LogLevel.Warn) + { + Console.MarkupLineInterpolated($"{Environment.NewLine}[default on yellow]Warning[/]: {msg}{Environment.NewLine}"); + } + } + + public void Log() + { + if (LogLevel >= LogLevel.Normal) + { + Console.WriteLine(); + } + } + + public void Log(string msg) + { + if (LogLevel >= LogLevel.Normal) + { + Console.WriteLine(msg); + } + } +} diff --git a/src/DNVM/ManifestSchema/Manifest.cs b/src/DNVM/ManifestSchema/Manifest.cs new file mode 100644 index 000000000000..e1363ad69f88 --- /dev/null +++ b/src/DNVM/ManifestSchema/Manifest.cs @@ -0,0 +1,117 @@ + +using System; +using System.Linq; +using Semver; +using Serde; +using StaticCs.Collections; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record Manifest +{ + public static readonly Manifest Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 8; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + + public bool PreviewsEnabled { get; init; } = false; + public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; + public EqArray InstalledSdks { get; init; } = []; + public EqArray RegisteredChannels { get; init; } = []; + + public Manifest TrackChannel(RegisteredChannel channel) + { + var existing = RegisteredChannels.FirstOrNull(c => + c.ChannelName == channel.ChannelName && c.SdkDirName == channel.SdkDirName); + if (existing is null) + { + return this with + { + RegisteredChannels = RegisteredChannels.Add(channel) + }; + } + else if (existing is { Untracked: true }) + { + var newVersions = existing.InstalledSdkVersions.Concat(channel.InstalledSdkVersions).Distinct().ToEq(); + return this with + { + RegisteredChannels = RegisteredChannels.Replace(existing, existing with + { + InstalledSdkVersions = newVersions, + Untracked = false, + }) + }; + } + throw new InvalidOperationException("Channel already tracked"); + } + + internal Manifest UntrackChannel(Channel channel) + { + return this with + { + RegisteredChannels = RegisteredChannels.Select(c => + { + if (c.ChannelName == channel) + { + return c with { Untracked = true }; + } + return c; + }).ToEq() + }; + } +} + +[GenerateSerde] +public partial record RegisteredChannel +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + [SerdeMemberOptions( + SerializeProxy = typeof(EqArrayProxy.Ser), + DeserializeProxy = typeof(EqArrayProxy.De))] + public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty; + public bool Untracked { get; init; } = false; +} + +[GenerateSerde] +public partial record InstalledSdk +{ + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion ReleaseVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion RuntimeVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion AspNetVersion { get; init; } + + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; +} + +public static partial class ManifestConvert +{ + public static Manifest Convert(this ManifestV7 v7) => new Manifest + { + InstalledSdks = v7.InstalledSdks.SelectAsArray(v => v.Convert()).ToEq(), + RegisteredChannels = v7.RegisteredChannels.SelectAsArray(c => c.Convert()).ToEq(), + }; + + public static InstalledSdk Convert(this InstalledSdkV7 v7) => new InstalledSdk { + ReleaseVersion = v7.ReleaseVersion, + SdkVersion = v7.SdkVersion, + RuntimeVersion = v7.RuntimeVersion, + AspNetVersion = v7.AspNetVersion, + SdkDirName = v7.SdkDirName, + }; + + public static RegisteredChannel Convert(this RegisteredChannelV7 v7) => new RegisteredChannel { + ChannelName = v7.ChannelName, + SdkDirName = v7.SdkDirName, + InstalledSdkVersions = v7.InstalledSdkVersions, + Untracked = v7.Untracked, + }; +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV1.cs b/src/DNVM/ManifestSchema/ManifestV1.cs new file mode 100644 index 000000000000..576b95db2e5f --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV1.cs @@ -0,0 +1,14 @@ + +using System.Collections.Immutable; +using Serde; + +namespace Dnvm; + +[GenerateDeserialize] +internal sealed partial record ManifestV1 +{ + public ImmutableArray Workloads { get; init; } = ImmutableArray.Empty; + + [GenerateSerde] + internal sealed partial record Workload(string Version); +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV2.cs b/src/DNVM/ManifestSchema/ManifestV2.cs new file mode 100644 index 000000000000..4e704892135b --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV2.cs @@ -0,0 +1,81 @@ + +using System; +using System.Collections.Immutable; +using System.Linq; +using Serde; + +namespace Dnvm; + +[GenerateSerde] +internal sealed partial record ManifestV2 +{ + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 2; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + public required ImmutableArray InstalledSdkVersions { get; init; } + public required ImmutableArray TrackedChannels { get; init; } + + public override string ToString() + { + return $"Manifest {{ Version = {Version}, " + + $"InstalledSdkVersion = [{InstalledSdkVersions.SeqToString()}, " + + $"TrackedChannels = [{TrackedChannels.SeqToString()}] }}"; + } + + public bool Equals(ManifestV2? other) + { + return other is not null && this.InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) && + this.TrackedChannels.SequenceEqual(other.TrackedChannels); + } + + public override int GetHashCode() + { + int code = 0; + foreach (var item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + foreach (var item in TrackedChannels) + { + code = HashCode.Combine(code, item); + } + return code; + } +} + +[GenerateSerde] +internal sealed partial record TrackedChannelV2 +{ + public required Channel ChannelName { get; init; } + public ImmutableArray InstalledSdkVersions { get; init; } + + public bool Equals(TrackedChannelV2? other) + { + return other is not null && + this.ChannelName == other.ChannelName && + this.InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions); + } + + public override int GetHashCode() + { + int code = ChannelName.GetHashCode(); + foreach (var item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + return code; + } +} + +static partial class ManifestV2Convert +{ + public static ManifestV2 Convert(this ManifestV1 v1) + { + return new ManifestV2 { + InstalledSdkVersions = v1.Workloads.Select(w => w.Version).ToImmutableArray(), + TrackedChannels = ImmutableArray.Empty + }; + } +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV3.cs b/src/DNVM/ManifestSchema/ManifestV3.cs new file mode 100644 index 000000000000..29fab4edd692 --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV3.cs @@ -0,0 +1,140 @@ + +using System; +using System.Collections.Immutable; +using System.Linq; +using Serde; +using StaticCs.Collections; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record ManifestV3 +{ + public static readonly ManifestV3 Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 3; + + [SerdeMemberOptions(SkipDeserialize = true)] + public static int Version => VersionField; + + public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty; + public ImmutableArray TrackedChannels { get; init; } = ImmutableArray.Empty; + + public override string ToString() + { + return $"Manifest {{ Version = {Version}, " + + $"InstalledSdkVersion = [{InstalledSdkVersions.SeqToString()}, " + + $"TrackedChannels = [{TrackedChannels.SeqToString()}] }}"; + } + + public bool Equals(ManifestV3? other) + { + return other is not null && InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) && + TrackedChannels.SequenceEqual(other.TrackedChannels); + } + + public override int GetHashCode() + { + int code = 0; + foreach (var item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + foreach (var item in TrackedChannels) + { + code = HashCode.Combine(code, item); + } + return code; + } +} + +[GenerateSerde] +public sealed partial record TrackedChannelV3 +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty; + + public bool Equals(TrackedChannelV3? other) + { + return other is not null && + ChannelName == other.ChannelName && + SdkDirName == other.SdkDirName && + InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions); + } + + public override int GetHashCode() + { + int code = 0; + code = HashCode.Combine(code, ChannelName); + code = HashCode.Combine(code, SdkDirName); + foreach (string item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + return code; + } +} + +[GenerateSerde] +public sealed partial record InstalledSdkV3 +{ + public required string Version { get; init; } + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; +} + +static class ManifestConvertV3 +{ + internal static ManifestV3 Convert(this ManifestV2 v2) + { + return new ManifestV3 { + InstalledSdkVersions = v2.InstalledSdkVersions.Select(v => new InstalledSdkV3 { + Version = v, + // Before V3, all SDKs were installed to the default dir + SdkDirName = DnvmEnv.DefaultSdkDirName + }).ToImmutableArray(), + TrackedChannels = v2.TrackedChannels.Select(c => new TrackedChannelV3 { + ChannelName = c.ChannelName, + SdkDirName = DnvmEnv.DefaultSdkDirName, + InstalledSdkVersions = c.InstalledSdkVersions + }).ToImmutableArray(), + }; + } +} + +public static partial class ManifestV3Utils +{ + public static ManifestV3 AddSdk(this ManifestV3 manifest, InstalledSdkV3 sdk, Channel c) + { + ManifestV3 newManifest; + if (manifest.TrackedChannels.FirstOrNull(x => x.ChannelName == c) is { } trackedChannel) + { + if (trackedChannel.InstalledSdkVersions.Contains(sdk.Version)) + { + return manifest; + } + newManifest = manifest with + { + TrackedChannels = manifest.TrackedChannels.Select(x => x.ChannelName == c + ? x with { InstalledSdkVersions = x.InstalledSdkVersions.Add(sdk.Version) } + : x).ToImmutableArray(), + InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk) + }; + } + else + { + newManifest = manifest with + { + TrackedChannels = manifest.TrackedChannels.Add(new TrackedChannelV3() + { + ChannelName = c, + SdkDirName = sdk.SdkDirName, + InstalledSdkVersions = ImmutableArray.Create(sdk.Version) + }), + InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk) + }; + } + return newManifest; + } +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV4.cs b/src/DNVM/ManifestSchema/ManifestV4.cs new file mode 100644 index 000000000000..aae78318483d --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV4.cs @@ -0,0 +1,156 @@ + +using System; +using System.Collections.Immutable; +using System.Linq; +using Serde; +using StaticCs.Collections; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record ManifestV4 +{ + public static readonly ManifestV4 Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 4; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + + public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; + public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty; + public ImmutableArray TrackedChannels { get; init; } = ImmutableArray.Empty; + + public override string ToString() + { + return $"ManifestV4 {{ Version = {Version}, " + + $"InstalledSdkV4Version = [{InstalledSdkVersions.SeqToString()}, " + + $"TrackedChannelV4s = [{TrackedChannels.SeqToString()}] }}"; + } + + public bool Equals(ManifestV4? other) + { + return other is not null && InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions) && + TrackedChannels.SequenceEqual(other.TrackedChannels); + } + + public override int GetHashCode() + { + int code = 0; + foreach (var item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + foreach (var item in TrackedChannels) + { + code = HashCode.Combine(code, item); + } + return code; + } + + internal ManifestV4 Untrack(Channel channel) + { + return this with + { + TrackedChannels = TrackedChannels.Where(c => c.ChannelName != channel).ToImmutableArray() + }; + } +} + +[GenerateSerde] +public sealed partial record TrackedChannelV4 +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + public ImmutableArray InstalledSdkVersions { get; init; } = ImmutableArray.Empty; + + public bool Equals(TrackedChannelV4? other) + { + return other is not null && + ChannelName == other.ChannelName && + SdkDirName == other.SdkDirName && + InstalledSdkVersions.SequenceEqual(other.InstalledSdkVersions); + } + + public override int GetHashCode() + { + int code = 0; + code = HashCode.Combine(code, ChannelName); + code = HashCode.Combine(code, SdkDirName); + foreach (string item in InstalledSdkVersions) + { + code = HashCode.Combine(code, item); + } + return code; + } +} + +[GenerateSerde] +public sealed partial record InstalledSdkV4 +{ + public required string Version { get; init; } + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; +} + +public static partial class ManifestV4Convert +{ + public static ManifestV4 Convert(this ManifestV3 v3) + { + return new ManifestV4 + { + InstalledSdkVersions = v3.InstalledSdkVersions.SelectAsArray(v => v.Convert()), + TrackedChannels = v3.TrackedChannels.SelectAsArray(c => c.Convert()), + }; + } + + public static InstalledSdkV4 Convert(this InstalledSdkV3 v3) + { + return new InstalledSdkV4 { + SdkDirName = v3.SdkDirName, + Version = v3.Version, + }; + } + + public static TrackedChannelV4 Convert(this TrackedChannelV3 v3) => new TrackedChannelV4 { + ChannelName = v3.ChannelName, + SdkDirName = v3.SdkDirName, + InstalledSdkVersions = v3.InstalledSdkVersions, + }; +} + +public static partial class ManifestV4Utils +{ + public static ManifestV4 AddSdk(this ManifestV4 manifest, InstalledSdkV4 sdk, Channel c) + { + ManifestV4 newManifest; + if (manifest.TrackedChannels.FirstOrNull(x => x.ChannelName == c) is { } trackedChannel) + { + if (trackedChannel.InstalledSdkVersions.Contains(sdk.Version)) + { + return manifest; + } + newManifest = manifest with + { + TrackedChannels = manifest.TrackedChannels.Select(x => x.ChannelName == c + ? x with { InstalledSdkVersions = x.InstalledSdkVersions.Add(sdk.Version) } + : x).ToImmutableArray(), + InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk) + }; + } + else + { + newManifest = manifest with + { + TrackedChannels = manifest.TrackedChannels.Add(new TrackedChannelV4() + { + ChannelName = c, + SdkDirName = sdk.SdkDirName, + InstalledSdkVersions = ImmutableArray.Create(sdk.Version) + }), + InstalledSdkVersions = manifest.InstalledSdkVersions.Add(sdk) + }; + } + return newManifest; + } +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV5.cs b/src/DNVM/ManifestSchema/ManifestV5.cs new file mode 100644 index 000000000000..38b8b45e6235 --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV5.cs @@ -0,0 +1,135 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Semver; +using Serde; +using Serde.Json; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record ManifestV5 +{ + public static readonly ManifestV5 Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 5; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + + public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; + public EqArray InstalledSdkVersions { get; init; } = []; + public EqArray TrackedChannels { get; init; } = []; + + internal ManifestV5 Untrack(Channel channel) + { + return this with + { + TrackedChannels = TrackedChannels.Where(c => c.ChannelName != channel).ToEq() + }; + } +} + +[GenerateSerde] +public partial record TrackedChannelV5 +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + [SerdeMemberOptions( + SerializeProxy = typeof(EqArrayProxy.Ser), + DeserializeProxy = typeof(EqArrayProxy.De))] + public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty; +} + +[GenerateSerde] +public partial record InstalledSdkV5 +{ + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion ReleaseVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion RuntimeVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion AspNetVersion { get; init; } + + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; + + /// + /// Indicates which channel this SDK was installed from, if any. + /// + public Channel? Channel { get; init; } = null; +} + +public static partial class ManifestV5Convert +{ + public static async Task Convert(this ManifestV4 v4, ScopedHttpClient httpClient, DotnetReleasesIndex releasesIndex) + { + var channelMemo = new SortedDictionary(SemVersion.SortOrderComparer); + + var getChannelIndex = async (SemVersion majorMinor) => + { + if (channelMemo.TryGetValue(majorMinor, out var channelReleaseIndex)) + { + return channelReleaseIndex; + } + + var channelRelease = releasesIndex.ChannelIndices.Single(r => r.MajorMinorVersion == majorMinor.ToMajorMinor()); + channelReleaseIndex = JsonSerializer.Deserialize( + await httpClient.GetStringAsync(channelRelease.ChannelReleaseIndexUrl)); + channelMemo[majorMinor] = channelReleaseIndex; + return channelReleaseIndex; + }; + + return new ManifestV5 + { + InstalledSdkVersions = (await v4.InstalledSdkVersions.SelectAsArray(v => v.Convert(v4, getChannelIndex))).ToEq(), + TrackedChannels = v4.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(), + }; + } + + public static async Task Convert( + this InstalledSdkV4 v4, + ManifestV4 manifestV4, + Func> getChannelIndex) + { + // Take the major and minor version from the installed SDK and use it to find the corresponding + // version in the releases index. Then grab the component versions from that release and fill + // in the remaining sections in the InstalledSdkV5 + var v4Version = SemVersion.Parse(v4.Version, SemVersionStyles.Strict); + var majorMinorVersion = new SemVersion(v4Version.Major, v4Version.Minor); + + var channelReleaseIndex = await getChannelIndex(majorMinorVersion); + var exactRelease = channelReleaseIndex.Releases + .Where(r => r.Sdks.Any(s => s.Version == v4Version)) + .Single(); + + + Channel? channel = (v4Version.Major, v4Version.Minor) switch { + (6, 0) => new Channel.Lts(), + (7, 0) => new Channel.Latest(), + (8, 0) => new Channel.Preview(), + _ => manifestV4.TrackedChannels + .SingleOrNull(c => c.SdkDirName == v4.SdkDirName)?.ChannelName + } ; + + return new InstalledSdkV5() + { + ReleaseVersion = exactRelease.ReleaseVersion, + SdkVersion = v4Version, + RuntimeVersion = exactRelease.Runtime.Version, + AspNetVersion = exactRelease.AspNetCore.Version, + SdkDirName = v4.SdkDirName, + Channel = channel + }; + } + + public static TrackedChannelV5 Convert(this TrackedChannelV4 v3) => new TrackedChannelV5 { + ChannelName = v3.ChannelName, + SdkDirName = v3.SdkDirName, + InstalledSdkVersions = v3.InstalledSdkVersions.Select(v => SemVersion.Parse(v, SemVersionStyles.Strict)).ToEq(), + }; +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV6.cs b/src/DNVM/ManifestSchema/ManifestV6.cs new file mode 100644 index 000000000000..dda99920deb0 --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV6.cs @@ -0,0 +1,86 @@ +using System.Linq; +using Semver; +using Serde; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record ManifestV6 +{ + public static readonly ManifestV6 Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 6; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + + public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; + public EqArray InstalledSdks { get; init; } = []; + public EqArray TrackedChannels { get; init; } = []; + + internal ManifestV6 Untrack(Channel channel) + { + return this with + { + TrackedChannels = TrackedChannels.Select(c => + { + if (c.ChannelName == channel) + { + return c with { Untracked = true }; + } + return c; + }).ToEq() + }; + } +} + +[GenerateSerde] +public partial record TrackedChannelV6 +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + [SerdeMemberOptions( + SerializeProxy = typeof(EqArrayProxy.Ser), + DeserializeProxy = typeof(EqArrayProxy.De))] + public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty; + public bool Untracked { get; init; } = false; +} + +[GenerateSerde] +public partial record InstalledSdkV6 +{ + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion ReleaseVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion RuntimeVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion AspNetVersion { get; init; } + + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; +} + +public static partial class ManifestV6Convert +{ + public static ManifestV6 Convert(this ManifestV5 v5) => new ManifestV6 + { + InstalledSdks = v5.InstalledSdkVersions.SelectAsArray(v => v.Convert()).ToEq(), + TrackedChannels = v5.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(), + }; + + public static InstalledSdkV6 Convert(this InstalledSdkV5 v5) => new InstalledSdkV6 { + ReleaseVersion = v5.ReleaseVersion, + SdkVersion = v5.SdkVersion, + RuntimeVersion = v5.RuntimeVersion, + AspNetVersion = v5.AspNetVersion, + SdkDirName = v5.SdkDirName, + }; + + public static TrackedChannelV6 Convert(this TrackedChannelV5 v5) => new TrackedChannelV6 { + ChannelName = v5.ChannelName, + SdkDirName = v5.SdkDirName, + InstalledSdkVersions = v5.InstalledSdkVersions + }; +} \ No newline at end of file diff --git a/src/DNVM/ManifestSchema/ManifestV7.cs b/src/DNVM/ManifestSchema/ManifestV7.cs new file mode 100644 index 000000000000..72545d85aa7b --- /dev/null +++ b/src/DNVM/ManifestSchema/ManifestV7.cs @@ -0,0 +1,116 @@ + +using System; +using System.Linq; +using Semver; +using Serde; +using StaticCs.Collections; + +namespace Dnvm; + +[GenerateSerde] +public sealed partial record ManifestV7 +{ + public static readonly ManifestV7 Empty = new(); + + // Serde doesn't serialize consts, so we have a separate property below for serialization. + public const int VersionField = 7; + + [SerdeMemberOptions(SkipDeserialize = true)] + public int Version => VersionField; + + public SdkDirName CurrentSdkDir { get; init; } = DnvmEnv.DefaultSdkDirName; + public EqArray InstalledSdks { get; init; } = []; + public EqArray RegisteredChannels { get; init; } = []; + + internal ManifestV7 TrackChannel(RegisteredChannelV7 channel) + { + var existing = RegisteredChannels.FirstOrNull(c => + c.ChannelName == channel.ChannelName && c.SdkDirName == channel.SdkDirName); + if (existing is null) + { + return this with + { + RegisteredChannels = RegisteredChannels.Add(channel) + }; + } + else if (existing is { Untracked: true }) + { + var newVersions = existing.InstalledSdkVersions.Concat(channel.InstalledSdkVersions).Distinct().ToEq(); + return this with + { + RegisteredChannels = RegisteredChannels.Replace(existing, existing with + { + InstalledSdkVersions = newVersions, + Untracked = false, + }) + }; + } + throw new InvalidOperationException("Channel already tracked"); + } + + internal ManifestV7 UntrackChannel(Channel channel) + { + return this with + { + RegisteredChannels = RegisteredChannels.Select(c => + { + if (c.ChannelName == channel) + { + return c with { Untracked = true }; + } + return c; + }).ToEq() + }; + } +} + +[GenerateSerde] +public partial record RegisteredChannelV7 +{ + public required Channel ChannelName { get; init; } + public required SdkDirName SdkDirName { get; init; } + [SerdeMemberOptions( + SerializeProxy = typeof(EqArrayProxy.Ser), + DeserializeProxy = typeof(EqArrayProxy.De))] + public EqArray InstalledSdkVersions { get; init; } = EqArray.Empty; + public bool Untracked { get; init; } = false; +} + +[GenerateSerde] +public partial record InstalledSdkV7 +{ + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion ReleaseVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion SdkVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion RuntimeVersion { get; init; } + [SerdeMemberOptions(Proxy = typeof(SemVersionProxy))] + public required SemVersion AspNetVersion { get; init; } + + public SdkDirName SdkDirName { get; init; } = DnvmEnv.DefaultSdkDirName; +} + +public static partial class ManifestV7Convert +{ + public static ManifestV7 Convert(this ManifestV6 v6) => new ManifestV7 + { + InstalledSdks = v6.InstalledSdks.SelectAsArray(v => v.Convert()).ToEq(), + RegisteredChannels = v6.TrackedChannels.SelectAsArray(c => c.Convert()).ToEq(), + }; + + public static InstalledSdkV7 Convert(this InstalledSdkV6 v6) => new InstalledSdkV7 { + ReleaseVersion = v6.ReleaseVersion, + SdkVersion = v6.SdkVersion, + RuntimeVersion = v6.RuntimeVersion, + AspNetVersion = v6.AspNetVersion, + SdkDirName = v6.SdkDirName, + }; + + public static RegisteredChannelV7 Convert(this TrackedChannelV6 v6) => new RegisteredChannelV7 { + ChannelName = v6.ChannelName, + SdkDirName = v6.SdkDirName, + InstalledSdkVersions = v6.InstalledSdkVersions, + Untracked = v6.Untracked, + }; +} \ No newline at end of file diff --git a/src/DNVM/ManifestUtils.cs b/src/DNVM/ManifestUtils.cs new file mode 100644 index 000000000000..e4a34fa7775f --- /dev/null +++ b/src/DNVM/ManifestUtils.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Semver; + +namespace Microsoft.DotNet.DNVM; + +/// +/// Holds the simple name of a directory that contains one or more SDKs and lives under DNVM_HOME. +/// This is a wrapper to prevent being used directly as a path. +/// +public sealed partial record SdkDirName(string Name) +{ + public string Name { get; init; } = Name.ToLower(); +} + +public static partial class ManifestUtils +{ + public static async Task ReadOrCreateManifest(DnvmEnv fs) + { + try + { + return await fs.ReadManifest(); + } + // Not found is expected + catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException) { } + + return Manifest.Empty; + } + + public static EqArray TrackedChannels(this Manifest manifest) + { + return manifest.RegisteredChannels.Where(x => !x.Untracked).ToEq(); + } + + /// + /// Calculates the version of the installed muxer. This is + /// Max(). + /// If no SDKs are installed, returns null. + /// + public static SemVersion? MuxerVersion(this Manifest manifest, SdkDirName dir) + { + var installedSdks = manifest + .InstalledSdks + .Where(s => s.SdkDirName == dir) + .ToList(); + if (installedSdks.Count == 0) + { + return null; + } + return installedSdks + .Select(s => s.RuntimeVersion) + .Max(SemVersion.SortOrderComparer); + } + + public static Manifest AddSdk( + this Manifest manifest, + SemVersion semVersion, + Channel? c = null, + SdkDirName? sdkDirParam = null) + { + if (sdkDirParam is not {} sdkDir) + { + sdkDir = DnvmEnv.DefaultSdkDirName; + } + var installedSdk = new InstalledSdk() { + SdkDirName = sdkDir, + SdkVersion = semVersion, + RuntimeVersion = semVersion, + AspNetVersion = semVersion, + ReleaseVersion = semVersion, + }; + return manifest.AddSdk(installedSdk, c); + } + + public static Manifest AddSdk(this Manifest manifest, + InstalledSdk sdk, + Channel? c = null) + { + var installedSdks = manifest.InstalledSdks; + if (!installedSdks.Contains(sdk)) + { + installedSdks = installedSdks.Add(sdk); + } + EqArray allChannels = manifest.RegisteredChannels; + if (allChannels.FirstOrNull(x => !x.Untracked && x.ChannelName == c && x.SdkDirName == sdk.SdkDirName) is { } oldTracked) + { + var installedSdkVersions = oldTracked.InstalledSdkVersions; + var newTracked = installedSdkVersions.Contains(sdk.SdkVersion) + ? oldTracked + : oldTracked with { + InstalledSdkVersions = installedSdkVersions.Add(sdk.SdkVersion) + }; + allChannels = allChannels.Replace(oldTracked, newTracked); + } + else if (c is not null) + { + allChannels = allChannels.Add(new RegisteredChannel { + ChannelName = c, + SdkDirName = sdk.SdkDirName, + InstalledSdkVersions = [ sdk.SdkVersion ] + }); + } + return manifest with { + InstalledSdks = installedSdks, + RegisteredChannels = allChannels, + }; + } + + public static bool IsSdkInstalled(Manifest manifest, SemVersion version, SdkDirName dirName) + { + return manifest.InstalledSdks.Any(s => s.SdkVersion == version && s.SdkDirName == dirName); + } + + /// + /// Either reads a manifest in the current format, or reads a + /// manifest in the old format and converts it to the new format. + /// + public static async Task DeserializeNewOrOldManifest( + ScopedHttpClient httpClient, + string manifestSrc, + IEnumerable releasesUrls) + { + var version = JsonSerializer.Deserialize(manifestSrc).Version; + // Handle versions that don't need the release index to convert + Manifest? manifest = version switch { + ManifestV5.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert().Convert().Convert(), + ManifestV6.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert().Convert(), + ManifestV7.VersionField => JsonSerializer.Deserialize(manifestSrc).Convert(), + Manifest.VersionField => JsonSerializer.Deserialize(manifestSrc), + _ => null + }; + if (manifest is not null) + { + return manifest; + } + + // Retrieve release index and convert + var releasesIndex = await DotnetReleasesIndex.FetchLatestIndex(httpClient, releasesUrls); + return version switch + { + // The first version didn't have a version field + null => (await JsonSerializer.Deserialize(manifestSrc) + .Convert().Convert().Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(), + ManifestV2.VersionField => (await JsonSerializer.Deserialize(manifestSrc) + .Convert().Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(), + ManifestV3.VersionField => (await JsonSerializer.Deserialize(manifestSrc) + .Convert().Convert(httpClient, releasesIndex)).Convert().Convert().Convert(), + ManifestV4.VersionField => (await JsonSerializer.Deserialize(manifestSrc) + .Convert(httpClient, releasesIndex)).Convert().Convert().Convert(), + _ => throw new InvalidDataException("Unknown manifest version: " + version) + }; + } + + private sealed partial class ManifestVersionOnly + { + public int? Version { get; init; } + } +} diff --git a/src/DNVM/Program.cs b/src/DNVM/Program.cs new file mode 100644 index 000000000000..330dd54db65a --- /dev/null +++ b/src/DNVM/Program.cs @@ -0,0 +1,82 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Semver; +using Spectre.Console; + +namespace Microsoft.DotNet.DNVM; + +public static class Program +{ + public static readonly SemVersion SemVer = SemVersion.Parse(GitVersionInformation.SemVer, SemVersionStyles.Strict); + + public static async Task Main(string[] args) + { + var console = AnsiConsole.Console; + if (!console.Profile.Out.IsTerminal) + { + // Set the width to a large, but reasonable, value to avoid wrapping. + console.Profile.Width = 255; + } + console.WriteLine("dnvm " + SemVer + " " + GitVersionInformation.ShortSha); + console.WriteLine(); + var logger = new Logger(console); + + var parsedArgs = CommandLineArguments.TryParse(console, args); + if (parsedArgs is null) + { + // Help was requested, exit with success. + return 0; + } + + // Self-install is special, since we don't know the DNVM_HOME yet. + if (parsedArgs.SubCommand is DnvmSubCommand.SelfInstallArgs selfInstallArgs) + { + return (int)await SelfInstallCommand.Run(logger, selfInstallArgs); + } + + using var env = DnvmEnv.CreateDefault(); + if (parsedArgs.SubCommand is null) + { + if (parsedArgs.EnableDnvmPreviews == true) + { + return await EnableDnvmPreviews(env); + } + else + { + // Help was requested, exit with success. + return 0; + } + } + + return await Dnvm(env, logger, parsedArgs); + } + + public static async Task EnableDnvmPreviews(DnvmEnv env) + { + var manifest = await ManifestUtils.ReadOrCreateManifest(env); + manifest = manifest with { PreviewsEnabled = true }; + env.WriteManifest(manifest); + return 0; + } + + internal static async Task Dnvm(DnvmEnv env, Logger logger, DnvmArgs args) + { + return args.SubCommand switch + { + DnvmSubCommand.TrackArgs a => (int)await TrackCommand.Run(env, logger, a), + DnvmSubCommand.InstallArgs a => (int)await InstallCommand.Run(env, logger, a), + DnvmSubCommand.UpdateArgs a => (int)await UpdateCommand.Run(env, logger, a), + DnvmSubCommand.ListArgs => (int)await ListCommand.Run(logger, env), + DnvmSubCommand.SelectArgs a => (int)await SelectCommand.Run(env, logger, new(a.SdkDirName)), + DnvmSubCommand.UntrackArgs a => await UntrackCommand.Run(env, logger, a.Channel), + DnvmSubCommand.UninstallArgs a => await UninstallCommand.Run(env, logger, a.SdkVersion, a.SdkDir), + DnvmSubCommand.PruneArgs a => await PruneCommand.Run(env, logger, a), + DnvmSubCommand.RestoreArgs a => await RestoreCommand.Run(env, logger, a) switch { + Result.Ok => 0, + Result.Err x => (int)x.Value, + }, + + DnvmSubCommand.SelfInstallArgs => throw ExceptionUtilities.Unreachable, + }; + } +} diff --git a/src/DNVM/Resources/env.sh b/src/DNVM/Resources/env.sh new file mode 100644 index 000000000000..9f488e71d69b --- /dev/null +++ b/src/DNVM/Resources/env.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Prepend dnvm and SDK dirs to the path, unless already there +case ":${PATH}:" in + *:"{install_loc}":*) + ;; + *) + export PATH="{install_loc}:$PATH" + ;; +esac +# Prepend global tools location +case ":${PATH}:" in + *:"$HOME/.dotnet/tools":*) + ;; + *) + export PATH="$HOME/.dotnet/tools:$PATH" + ;; +esac +export DOTNET_ROOT="{sdk_install_loc}" \ No newline at end of file diff --git a/src/DNVM/ScalarDeserializer.cs b/src/DNVM/ScalarDeserializer.cs new file mode 100644 index 000000000000..7f82bddb7865 --- /dev/null +++ b/src/DNVM/ScalarDeserializer.cs @@ -0,0 +1,52 @@ + +using System; +using System.Buffers; + +namespace Microsoft.DotNet.DNVM; + +public sealed class ScalarDeserializer(string s) : IDeserializer +{ + public bool ReadBool() + => bool.Parse(s); + + public byte ReadU8() + => byte.Parse(s); + + public char ReadChar() + => char.Parse(s); + + public decimal ReadDecimal() + => decimal.Parse(s); + + public double ReadF64() => double.Parse(s); + + public float ReadF32() => float.Parse(s); + + public short ReadI16() => short.Parse(s); + + public int ReadI32() => int.Parse(s); + + public long ReadI64() => long.Parse(s); + + public sbyte ReadI8() => sbyte.Parse(s); + + public string ReadString() => s; + + public ushort ReadU16() => ushort.Parse(s); + + public uint ReadU32() => uint.Parse(s); + + public ulong ReadU64() => ulong.Parse(s); + + public DateTime ReadDateTime() + => DateTime.Parse(s); + public void ReadBytes(IBufferWriter writer) + => throw new DeserializeException("Found bytes, expected scalar"); + + void IDisposable.Dispose() { } + + T IDeserializer.ReadNullableRef(IDeserialize deserialize) + { + return deserialize.Deserialize(this); + } +} diff --git a/src/DNVM/SerdeWraps/SdkDirNameProxy.cs b/src/DNVM/SerdeWraps/SdkDirNameProxy.cs new file mode 100644 index 000000000000..4c9bd4066f2f --- /dev/null +++ b/src/DNVM/SerdeWraps/SdkDirNameProxy.cs @@ -0,0 +1,21 @@ + +using Serde; + +namespace Dnvm; + +/// +/// Serializes the directly as a string. +/// +internal sealed class SdkDirNameProxy : ISerde, ISerdeProvider +{ + public static SdkDirNameProxy Instance { get; } = new(); + public ISerdeInfo SerdeInfo => StringProxy.SerdeInfo; + + public SdkDirName Deserialize(IDeserializer deserializer) + => new SdkDirName(StringProxy.Instance.Deserialize(deserializer)); + + public void Serialize(SdkDirName value, ISerializer serializer) + { + serializer.WriteString(value.Name); + } +} \ No newline at end of file diff --git a/src/DNVM/SerdeWraps/SemVersionProxy.cs b/src/DNVM/SerdeWraps/SemVersionProxy.cs new file mode 100644 index 000000000000..c910672c90dd --- /dev/null +++ b/src/DNVM/SerdeWraps/SemVersionProxy.cs @@ -0,0 +1,32 @@ + +using System; +using Semver; +using Serde; + +namespace Dnvm; + +/// +/// Serializes as a string. +/// +internal sealed class SemVersionProxy : ISerde, ISerdeProvider +{ + static SemVersionProxy ISerdeProvider.Instance { get; } = new SemVersionProxy(); + private SemVersionProxy() { } + + public ISerdeInfo SerdeInfo { get; } = StringProxy.SerdeInfo; + + public SemVersion Deserialize(IDeserializer deserializer) + { + var str = deserializer.ReadString(); + if (SemVersion.TryParse(str, SemVersionStyles.Strict, out var version)) + { + return version; + } + throw new DeserializeException($"Version string '{str}' is not a valid SemVersion."); + } + + public void Serialize(SemVersion value, ISerializer serializer) + { + serializer.WriteString(value.ToString()); + } +} \ No newline at end of file diff --git a/src/DNVM/UninstallCommand.cs b/src/DNVM/UninstallCommand.cs new file mode 100644 index 000000000000..40fcbd869631 --- /dev/null +++ b/src/DNVM/UninstallCommand.cs @@ -0,0 +1,143 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Semver; +using Zio; + +namespace Microsoft.DotNet.DNVM; + +public sealed class UninstallCommand +{ + public static async Task Run(DnvmEnv env, Logger logger, SemVersion sdkVersion, SdkDirName? dir = null) + { + Manifest manifest; + try + { + manifest = await env.ReadManifest(); + } + catch (Exception e) + { + logger.Error($"Error reading manifest: {e.Message}"); + throw; + } + + var runtimesToKeep = new HashSet<(SemVersion, SdkDirName)>(); + var runtimesToRemove = new HashSet<(SemVersion, SdkDirName)>(); + var sdksToRemove = new HashSet<(SemVersion, SdkDirName)>(); + var aspnetToKeep = new HashSet<(SemVersion, SdkDirName)>(); + var aspnetToRemove = new HashSet<(SemVersion, SdkDirName)>(); + var winToKeep = new HashSet<(SemVersion, SdkDirName)>(); + var winToRemove = new HashSet<(SemVersion, SdkDirName)>(); + + foreach (var installed in manifest.InstalledSdks) + { + if (installed.SdkVersion == sdkVersion && (dir is null || installed.SdkDirName == dir)) + { + sdksToRemove.Add((installed.SdkVersion, installed.SdkDirName)); + runtimesToRemove.Add((installed.RuntimeVersion, installed.SdkDirName)); + aspnetToRemove.Add((installed.AspNetVersion, installed.SdkDirName)); + winToRemove.Add((installed.ReleaseVersion, installed.SdkDirName)); + } + else + { + runtimesToKeep.Add((installed.RuntimeVersion, installed.SdkDirName)); + aspnetToKeep.Add((installed.AspNetVersion, installed.SdkDirName)); + winToKeep.Add((installed.ReleaseVersion, installed.SdkDirName)); + } + } + + if (sdksToRemove.Count == 0) + { + logger.Error($"SDK version {sdkVersion} is not installed."); + return 1; + } + + runtimesToRemove.ExceptWith(runtimesToKeep); + aspnetToRemove.ExceptWith(aspnetToKeep); + winToRemove.ExceptWith(winToKeep); + + DeleteSdks(env, sdksToRemove, logger); + DeleteRuntimes(env, runtimesToRemove, logger); + DeleteAspnets(env, aspnetToRemove, logger); + DeleteWins(env, winToRemove, logger); + + manifest = UninstallSdk(manifest, sdkVersion); + env.WriteManifest(manifest); + + return 0; + } + + private static void DeleteSdks(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> sdks, Logger logger) + { + foreach (var (version, dir) in sdks) + { + var verString = version.ToString(); + var sdkDir = DnvmEnv.GetSdkPath(dir) / "sdk" / verString; + + logger.Log($"Deleting SDK {verString} from {dir.Name}"); + + env.DnvmHomeFs.DeleteDirectory(sdkDir, isRecursive: true); + } + } + + private static void DeleteRuntimes(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> runtimes, Logger logger) + { + foreach (var (version, dir) in runtimes) + { + var verString = version.ToString(); + var netcoreappDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.NETCore.App" / verString; + var hostfxrDir = DnvmEnv.GetSdkPath(dir) / "host" / "fxr" / verString; + var packsHostDir = DnvmEnv.GetSdkPath(dir) / "packs" / $"Microsoft.NETCore.App.Host.{Utilities.CurrentRID}" / verString; + + logger.Log($"Deleting Runtime {verString} from {dir.Name}"); + + env.DnvmHomeFs.DeleteDirectory(netcoreappDir, isRecursive: true); + env.DnvmHomeFs.DeleteDirectory(hostfxrDir, isRecursive: true); + env.DnvmHomeFs.DeleteDirectory(packsHostDir, isRecursive: true); + } + } + + private static void DeleteAspnets(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> aspnets, Logger logger) + { + foreach (var (version, dir) in aspnets) + { + var verString = version.ToString(); + var aspnetDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.AspNetCore.App" / verString; + var templatesDir = DnvmEnv.GetSdkPath(dir) / "templates" / verString; + + logger.Log($"Deleting ASP.NET pack {verString} from {dir.Name}"); + + env.DnvmHomeFs.DeleteDirectory(aspnetDir, isRecursive: true); + env.DnvmHomeFs.DeleteDirectory(templatesDir, isRecursive: true); + } + } + + private static void DeleteWins(DnvmEnv env, IEnumerable<(SemVersion, SdkDirName)> wins, Logger logger) + { + foreach (var (version, dir) in wins) + { + var verString = version.ToString(); + var winDir = DnvmEnv.GetSdkPath(dir) / "shared" / "Microsoft.WindowsDesktop.App" / verString; + + if (env.DnvmHomeFs.DirectoryExists(winDir)) + { + logger.Log($"Deleting Windows Desktop pack {verString} from {dir.Name}"); + + env.DnvmHomeFs.DeleteDirectory(winDir, isRecursive: true); + } + } + } + + private static Manifest UninstallSdk(Manifest manifest, SemVersion sdkVersion) + { + // Delete SDK version from all directories + var newVersions = manifest.InstalledSdks + .Where(sdk => sdk.SdkVersion != sdkVersion) + .ToEq(); + return manifest with { + InstalledSdks = newVersions, + }; + } +} diff --git a/src/DNVM/Utilities/CancelScope.cs b/src/DNVM/Utilities/CancelScope.cs new file mode 100644 index 000000000000..e071b28c0de7 --- /dev/null +++ b/src/DNVM/Utilities/CancelScope.cs @@ -0,0 +1,129 @@ + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Dnvm; + +public sealed partial class CancelScope +{ + private readonly CancelScope? _parent; + private readonly CancellationTokenSource _cts; + + private CancelScope(CancelScope? parent) + { + _parent = parent; + _cts = parent is null + ? new() + : CancellationTokenSource.CreateLinkedTokenSource(parent._cts.Token); + } +} + +partial class CancelScope +{ + private static readonly AsyncLocal _current = new AsyncLocal(); + + public static CancelScope Current + { + get + { + if (_current.Value is null) + { + _current.Value = new CancelScope(null); + } + return _current.Value; + } + private set + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + _current.Value = value; + } + } + + public static async Task WithCancelAfter( + TimeSpan delay, + Func func, + Action? onCanceled = null) + { + var parent = Current; + Debug.Assert(parent is not null); + var scope = new CancelScope(Current); + Current = scope; + try + { + scope._cts.CancelAfter(delay); + await func(scope); + } + catch (OperationCanceledException e) when (e.CancellationToken == scope._cts.Token) + { + onCanceled?.Invoke(e); + } + finally + { + scope._cts.Dispose(); + Debug.Assert(parent is not null); + Current = parent; + } + } + + public static void WithCancelAfter( + TimeSpan delay, + Action action, + Action? onCanceled = null) + { + WithCancelAfter( + delay, + scope => { action(scope); return Task.CompletedTask; }, + onCanceled).GetAwaiter().GetResult(); + } + + public static T WithTimeoutAfter( + TimeSpan delay, + Func func, + Action? onCanceled = null) + { + return WithTimeoutAfter( + delay, + scope => Task.FromResult(func(scope)), + onCanceled).GetAwaiter().GetResult(); + } + + public static async Task WithTimeoutAfter( + TimeSpan delay, + Func> func, + Action? onCanceled = null) + { + var parent = Current; + Debug.Assert(parent is not null); + var scope = new CancelScope(Current); + Current = scope; + try + { + scope._cts.CancelAfter(delay); + return await func(scope); + } + catch (OperationCanceledException e) when (e.CancellationToken == scope._cts.Token) + { + onCanceled?.Invoke(e); + throw new TimeoutException("Operation timed out", e); + } + finally + { + scope._cts.Dispose(); + Debug.Assert(parent is not null); + Current = parent; + } + } + + public CancellationToken Token => _cts.Token; + + public void Cancel() + { + _cts.Cancel(); + _cts.Token.ThrowIfCancellationRequested(); + } +} \ No newline at end of file diff --git a/src/DNVM/Utilities/EqArray.cs b/src/DNVM/Utilities/EqArray.cs new file mode 100644 index 000000000000..0e1b006b690e --- /dev/null +++ b/src/DNVM/Utilities/EqArray.cs @@ -0,0 +1,90 @@ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using Serde; + +namespace Dnvm; + +public static class EqArray +{ + public static EqArray ToEq(this IEnumerable array) => new(array.ToImmutableArray()); + public static EqArray ToEq(this ImmutableArray array) => new(array); + public static EqArray Create(params T[] array) => ImmutableArray.Create(array).ToEq(); + public static EqArray Create(ReadOnlySpan span) => ImmutableArray.Create(span).ToEq(); +} + +[SerdeTypeOptions(Proxy = typeof(EqArrayProxy))] +[CollectionBuilder(typeof(EqArray), nameof(EqArray.Create))] +public readonly struct EqArray(ImmutableArray value) : IReadOnlyCollection, IEquatable> +{ + public ImmutableArray Array => value; + + public static readonly EqArray Empty = ImmutableArray.Empty.ToEq(); + + public int Length => value.Length; + + int IReadOnlyCollection.Count => value.Length; + + public override bool Equals(object? obj) => obj is EqArray other && Equals(other); + + public bool Equals(EqArray other) => value.SequenceEqual(other.Array); + + public IEnumerator GetEnumerator() => ((IEnumerable)value).GetEnumerator(); + + public override int GetHashCode() + { + return value.Aggregate(0, (acc, item) => HashCode.Combine(acc, item)); + } + + public override string ToString() + { + return "[ " + string.Join(", ", value) + " ]"; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)value).GetEnumerator(); + } + + public T this[int index] => value[index]; + + public EqArray Add(T item) => new(value.Add(item)); + + public EqArray Replace(T oldItem, T newItem) => new(value.Replace(oldItem, newItem)); +} + +public static class EqArrayProxy +{ + private static readonly ISerdeInfo s_typeInfo = Serde.SerdeInfo.MakeEnumerable(nameof(EqArray)); + public sealed class Ser : ISerializeProvider>, ISerialize> + where TProvider : ISerializeProvider + { + public static readonly Ser Instance = new(); + static ISerialize> ISerializeProvider>.Instance => Instance; + + public ISerdeInfo SerdeInfo => s_typeInfo; + + void ISerialize>.Serialize(EqArray value, ISerializer serializer) + { + ImmutableArrayProxy.Ser.Instance.Serialize(value.Array, serializer); + } + } + + public sealed class De : IDeserializeProvider>, IDeserialize> + where TProvider : IDeserializeProvider + { + public static readonly De Instance = new(); + static IDeserialize> IDeserializeProvider>.Instance => Instance; + + public ISerdeInfo SerdeInfo => s_typeInfo; + EqArray IDeserialize>.Deserialize(IDeserializer deserializer) + { + return ImmutableArrayProxy.De.Instance.Deserialize(deserializer).ToEq(); + } + } +} \ No newline at end of file diff --git a/src/DNVM/Utilities/ExceptionUtilities.cs b/src/DNVM/Utilities/ExceptionUtilities.cs new file mode 100644 index 000000000000..e3675097d1ff --- /dev/null +++ b/src/DNVM/Utilities/ExceptionUtilities.cs @@ -0,0 +1,9 @@ +using System; + +namespace Dnvm +{ + internal class ExceptionUtilities + { + public static Exception Unreachable => new("This location is thought to be unreachable"); + } +} \ No newline at end of file diff --git a/src/DNVM/Utilities/Extensions.cs b/src/DNVM/Utilities/Extensions.cs new file mode 100644 index 000000000000..64e17f6d4bf9 --- /dev/null +++ b/src/DNVM/Utilities/Extensions.cs @@ -0,0 +1,59 @@ + +using System; +using System.Collections.Immutable; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Dnvm; + +internal static class ImmutableArrayExt +{ + public static async Task> SelectAsArray(this ImmutableArray e, Func> f) + { + var builder = ImmutableArray.CreateBuilder(e.Length); + foreach (var item in e) + { + builder.Add(await f(item)); + } + return builder.MoveToImmutable(); + } + + public static T? SingleOrNull(this ImmutableArray e, Func func) + where T : class + { + T? result = null; + foreach (var elem in e) + { + if (func(elem)) + { + if (result is not null) + { + return null; + } + result = elem; + } + } + return result; + } +} + +internal static class ImmutableArrayExt2 +{ + public static T? SingleOrNull(this ImmutableArray e, Func func) + where T : struct + { + T? result = null; + foreach (var elem in e) + { + if (func(elem)) + { + if (result is not null) + { + return null; + } + result = elem; + } + } + return result; + } +} \ No newline at end of file diff --git a/src/DNVM/Utilities/ProcUtil.cs b/src/DNVM/Utilities/ProcUtil.cs new file mode 100644 index 000000000000..00d7ecfba70c --- /dev/null +++ b/src/DNVM/Utilities/ProcUtil.cs @@ -0,0 +1,72 @@ + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace Dnvm; + +public static class ProcUtil +{ + public static async Task RunWithOutput(string cmd, string args, Dictionary? envVars = null) + { + var psi = new ProcessStartInfo { + FileName = cmd, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + if (envVars is not null) + { + foreach (var (k, v) in envVars) + { + psi.Environment[k] = v; + } + } + var proc = Process.Start(psi); + if (proc is null) + { + throw new IOException($"Could not start process: " + cmd); + } + + var @out = proc.StandardOutput.ReadToEndAsync(); + var error = proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync().ConfigureAwait(false); + return new ProcResult( + proc.ExitCode, + await @out.ConfigureAwait(false), + await error.ConfigureAwait(false)); + } + + public static async Task RunShell( + string scriptContent, + Dictionary? envVars = null) + { + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true + }; + if (envVars is not null) + { + foreach (var (k, v) in envVars) + { + psi.Environment[k] = v; + } + } + var proc = Process.Start(psi)!; + await proc.StandardInput.WriteAsync(scriptContent); + proc.StandardInput.Close(); + var @out = proc.StandardOutput.ReadToEndAsync(); + var error = proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync().ConfigureAwait(false); + return new ProcResult( + proc.ExitCode, + await @out.ConfigureAwait(false), + await error.ConfigureAwait(false)); + } + + public sealed record ProcResult(int ExitCode, string Out, string Error); +} \ No newline at end of file diff --git a/src/DNVM/Utilities/ScopedHttpClient.cs b/src/DNVM/Utilities/ScopedHttpClient.cs new file mode 100644 index 000000000000..5c786f377a59 --- /dev/null +++ b/src/DNVM/Utilities/ScopedHttpClient.cs @@ -0,0 +1,76 @@ + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Dnvm; + +public sealed class ScopedHttpClient(HttpClient client) +{ + internal async Task GetAsync([StringSyntax("Uri")] string url) + { + return new ScopedHttpResponseMessage( + await client.GetAsync(url, CancelScope.Current.Token) + ); + } + + internal async Task GetAsync( + [StringSyntax("Uri")] string url, + HttpCompletionOption completionOption) + { + return new ScopedHttpResponseMessage( + await client.GetAsync(url, completionOption, CancelScope.Current.Token) + ); + } + + internal Task GetStringAsync(string url) + { + // This is a commonly used method for small requests, so we set a default timeout in case + // the caller forgets to set one + return CancelScope.WithTimeoutAfter( + DnvmEnv.DefaultTimeout, + _ => client.GetStringAsync(url, CancelScope.Current.Token) + ); + } + + internal Task GetStreamAsync(string uri) + => client.GetStreamAsync(uri, CancelScope.Current.Token); +} + +public sealed class ScopedHttpResponseMessage(HttpResponseMessage response) : IDisposable +{ + public bool IsSuccessStatusCode => response.IsSuccessStatusCode; + + public ScopedHttpContent Content => new(response.Content); + + public void Dispose() => response.Dispose(); + + public void EnsureSuccessStatusCode() => response.EnsureSuccessStatusCode(); +} + +public sealed class ScopedHttpContent(HttpContent content) +{ + public HttpContentHeaders Headers => content.Headers; + + public Task ReadAsStringAsync() + { + return content.ReadAsStringAsync(CancelScope.Current.Token); + } + + public async Task ReadAsStreamAsync() + { + return new(await content.ReadAsStreamAsync(CancelScope.Current.Token)); + } +} + +public sealed class ScopedStream(Stream stream) : IDisposable +{ + public void Dispose() => stream.Dispose(); + + public ValueTask ReadAsync(Memory buffer) + => stream.ReadAsync(buffer, CancelScope.Current.Token); +} \ No newline at end of file diff --git a/src/DNVM/Utilities/Utilities.cs b/src/DNVM/Utilities/Utilities.cs new file mode 100644 index 000000000000..e8b4d4c84b2a --- /dev/null +++ b/src/DNVM/Utilities/Utilities.cs @@ -0,0 +1,377 @@ + +using System; +using System.Buffers; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; +using System.IO.Enumeration; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using System.Threading.Tasks; +using Semver; +using Serde; +using Serde.Json; +using Spectre.Console; +using StaticCs; +using Zio; +using Zio.FileSystems; + +namespace Dnvm; + +/// +/// Deletes the given directory on disposal. +/// +public sealed record DirectoryResource( + string Path, + bool Recursive = true) : IDisposable +{ + public void Dispose() + { + Directory.Delete(Path, recursive: Recursive); + } +} + +public static class SpectreUtil +{ + public static Task DownloadWithProgress( + this Logger logger, + ScopedHttpClient client, + string filePath, + string url, + string description, + int? bufferSizeParam = null) + { + var console = logger.Console; + return console.Progress() + .AutoRefresh(true) + .Columns( + new TaskDescriptionColumn(), + new ProgressBarColumn(), + new PercentageColumn()) + .StartAsync(async ctx => + { + using var archiveResponse = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout, + _ => client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)); + if (!archiveResponse.IsSuccessStatusCode) + { + return await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout, + _ => archiveResponse.Content.ReadAsStringAsync()); + } + + if (archiveResponse.Content.Headers.ContentLength is not { } contentLength) + { + throw new InvalidDataException("HTTP Content length is null"); + } + + // Use 1/100 of the file size as the buffer size, up to 1 MB. + const int oneMb = 1024 * 1024; + var bufferSize = bufferSizeParam ?? (int)Math.Min(contentLength / 100, oneMb); + logger.Info($"Buffer size: {bufferSize} bytes"); + + var progressTask = ctx.AddTask(description, maxValue: contentLength); + console.MarkupLine($"Starting download (size: {contentLength / oneMb:0.00} MB)"); + + using var tempArchiveFile = new FileStream( + filePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, // 64kB + FileOptions.Asynchronous | FileOptions.SequentialScan); + + using var archiveHttpStream = await CancelScope.WithTimeoutAfter(DnvmEnv.DefaultTimeout, + _ => archiveResponse.Content.ReadAsStreamAsync()); + + var buffer = ArrayPool.Shared.Rent(bufferSize); + while (true) + { + // We could have a timeout for downloading the file, but this is heavily dependent + // on the user's download speed and if they suspend/resume the process. Instead + // we'll rely on the user to cancel the download if it's taking too long. + var read = await archiveHttpStream.ReadAsync(buffer); + if (read == 0) + { + break; + } + // Writing to disk shouldn't time out, but we'll check for cancellation + await tempArchiveFile.WriteAsync(buffer.AsMemory(0, read), CancelScope.Current.Token); + progressTask.Increment(read); + ctx.Refresh(); + } + await tempArchiveFile.FlushAsync(CancelScope.Current.Token); + ArrayPool.Shared.Return(buffer); + progressTask.StopTask(); + return null; + }); + } +} + +public static class Utilities +{ + /// + /// Do our best to replace the target file with the source file. If the target file is locked, + /// we will try to delete it and then copy the source file over. If the target file cannot be + /// deleted, we will try to move it to the same folder with an additional ".old" suffix. + /// + public static void ForceReplaceFile(IFileSystem srcFs, UPath src, IFileSystem destFs, UPath dest) + { + try + { + // Does not throw if the file does not exist + destFs.DeleteFile(dest); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + var destDir = dest.GetDirectory(); + var destName = dest.GetName(); + var destOld = destDir / (destName + ".old"); + destFs.MoveFile(dest, destOld); + } + srcFs.MoveFileCross(src, destFs, dest); + } + + public static readonly string ZipSuffix = Environment.OSVersion.Platform == PlatformID.Win32NT ? ".zip" : ".tar.gz"; + + [UnsupportedOSPlatform("windows")] + public static void ChmodExec(string path) + { + var mod = File.GetUnixFileMode(path); + File.SetUnixFileMode(path, mod | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + + [UnsupportedOSPlatform("windows")] + public static void ChmodExec(IFileSystem vfs, UPath upath) + { + var realPath = vfs.ConvertPathToInternal(upath); + var mod = File.GetUnixFileMode(realPath); + File.SetUnixFileMode(realPath, mod | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + + public static string ToMajorMinor(this SemVersion version) => $"{version.Major}.{version.Minor}"; + + public static string ToFeature(this SemVersion version) + { + int feature = version.Patch; + while (feature >= 10) + { + feature /= 10; + } + + return $"{version.Major}.{version.Minor}.{feature}xx"; + } + + public static string SeqToString(this IEnumerable e) + { + return "[ " + string.Join(", ", e) + " ]"; + } + + public static ImmutableArray SelectAsArray(this ImmutableArray e, Func f) + { + var builder = ImmutableArray.CreateBuilder(e.Length); + foreach (var item in e) + { + builder.Add(f(item)); + } + return builder.MoveToImmutable(); + } + + public static EqArray SelectAsArray(this EqArray e, Func f) + { + var builder = ImmutableArray.CreateBuilder(e.Length); + foreach (var item in e) + { + builder.Add(f(item)); + } + return new(builder.MoveToImmutable()); + } + + public static readonly RID CurrentRID = new RID( + GetCurrentOSPlatform(), + RuntimeInformation.OSArchitecture, + RuntimeInformation.RuntimeIdentifier.Contains("musl") ? Libc.Musl : Libc.Default); + + private static OSPlatform GetCurrentOSPlatform() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OSPlatform.OSX : + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OSPlatform.Windows : + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? OSPlatform.Linux : + throw new NotSupportedException("Current OS is not supported: " + RuntimeInformation.OSDescription); + } + + [UnconditionalSuppressMessage("SingleFile", "IL3000", Justification = "Checks for empty location")] + public static bool IsSingleFile => Assembly.GetExecutingAssembly()?.Location == ""; + + public static string ProcessPath = Environment.ProcessPath + ?? throw new InvalidOperationException("Cannot find exe name"); + + public static string ExeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ".exe" + : ""; + + public static string DnvmExeName = "dnvm" + ExeSuffix; + public static string DotnetExeName = "dotnet" + ExeSuffix; + + public static async Task ExtractArchiveToDir(string archivePath, string dirPath) + { + Directory.CreateDirectory(dirPath); + if (Utilities.CurrentRID.OS != OSPlatform.Windows) + { + var procResult = await ProcUtil.RunWithOutput("tar", $"-xzf \"{archivePath}\" -C \"{dirPath}\""); + return procResult.ExitCode == 0 ? null : procResult.Error; + } + else + { + try + { + ZipFile.ExtractToDirectory(archivePath, dirPath, overwriteFiles: true); + } + catch (Exception e) + { + return e.Message; + } + } + return null; + } + + public static async Task ExtractSdkToDir( + SemVersion? existingMuxerVersion, + SemVersion runtimeVersion, + string archivePath, + IFileSystem tempFs, + IFileSystem destFs, + UPath destDir) + { + destFs.CreateDirectory(destDir); + var tempExtractDir = UPath.Root / Path.GetRandomFileName(); + tempFs.CreateDirectory(tempExtractDir); + using var tempRealPath = new DirectoryResource(tempFs.ConvertPathToInternal(tempExtractDir)); + if (Utilities.CurrentRID.OS != OSPlatform.Windows) + { + 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; + } + } + + 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(destDir / dir.GetName()); + foreach (var fsItem in tempFs.EnumerateItems(dir, SearchOption.AllDirectories)) + { + var relativePath = fsItem.Path.FullName[extractFullName.Length..].TrimStart('/'); + var destPath = 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( + SemVersion? existingMuxerVersion, + SemVersion newRuntimeVersion, + IFileSystem tempFs, + UPath tempExtractDir, + IFileSystem destFs, + UPath 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 = 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, tempExtractDir / DotnetExeName, destFs, muxerTargetPath); + } + + public static T Unwrap(this T? t, [CallerArgumentExpression(parameterName: nameof(t))] string? expr = null) where T : class + { + if (t is null) + { + throw new NullReferenceException("Unexpected null value in expression: " + (expr ?? "(null)")); + } + return t; + } +} + +[Closed] +public enum Libc +{ + Default, // Not a real libc, refers to the most common platform libc + Musl +} + +public sealed record RID( + OSPlatform OS, + Architecture Arch, + Libc Libc = Libc.Default) +{ + public override string ToString() + { + string os = + OS == OSPlatform.Windows ? "win" : + OS == OSPlatform.Linux ? "linux" : + OS == OSPlatform.OSX ? "osx" : + throw new NotSupportedException("Unsupported OS: " + OS); + + string arch = Arch switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new NotSupportedException("Unsupported architecture") + }; + return Libc switch + { + Libc.Default => string.Join("-", os, arch), + Libc.Musl => string.Join('-', os, arch, "musl") + }; + } +} diff --git a/src/DNVM/dnvm.csproj b/src/DNVM/dnvm.csproj new file mode 100644 index 000000000000..a230ad234881 --- /dev/null +++ b/src/DNVM/dnvm.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + true + true + true + + + + + + + + + + + + +