diff --git a/.vsts-dnup-ci.yml b/.vsts-dnup-ci.yml new file mode 100644 index 000000000000..f0523037a8d6 --- /dev/null +++ b/.vsts-dnup-ci.yml @@ -0,0 +1,130 @@ +# Pipeline: https://dev.azure.com/dnceng/internal/_build?definitionId= + +trigger: + batch: true + branches: + include: + - dnup + - release/dnup + +pr: + branches: + include: + - dnup + - release/dnup + +parameters: +# When true, runs the pipeline in the same way as the PR pipeline. +- name: runTestBuild + displayName: Run A Test Build + type: boolean + default: false +- name: enableArm64Job + displayName: Enables the ARM64 job + type: boolean + default: false + +variables: +- template: /eng/pipelines/templates/variables/sdk-defaults.yml +# Variables used: DncEngInternalBuildPool +- template: /eng/common/templates-official/variables/pool-providers.yml +# Helix testing requires a token when internally run. +# Variables used: HelixApiAccessToken +- group: DotNet-HelixApi-Access +- group: AzureDevOps-Artifact-Feeds-Pats +# Allows Arcade to run a signed build by disabling post-build signing for release-branch builds or manual builds that are not running tests. +- ${{ if and(eq(parameters.runTestBuild, false), or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), eq(variables['Build.Reason'], 'Manual'))) }}: + - name: PostBuildSign + value: false +# Provides TSA variables for automatic bug reporting. +- ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + - group: DotNet-CLI-SDLValidation-Params +### LOCAL ONLY ### +- name: _publishArgument + value: -publish +- name: _signArgument + value: -sign /p:SignCoreSdk=true +- name: _officialBuildProperties + # The OfficialBuilder property is set to Microsoft for the official build only. + # This property is checked in Directory.Build.props and adds the MICROSOFT_ENABLE_TELEMETRY constant. + # This constant is used in CompileOptions.cs to set both TelemetryOptOutDefault and TelemetryOptOutDefaultString. + value: /p:DotNetPublishUsingPipelines=true /p:OfficialBuilder=Microsoft /p:OfficialBuildId=$(Build.BuildNumber) + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + ${{ else }}: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines + parameters: + containers: + azureLinux30Amd64: + image: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-build-amd64 + + sdl: + sourceAnalysisPool: + name: $(DncEngInternalBuildPool) + image: 1es-windows-2022 + os: windows + policheck: + enabled: true + tsa: + enabled: true + binskim: + enabled: true + ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}: + componentgovernance: + # Refdoc: https://docs.opensource.microsoft.com/tools/cg/component-detection/variables/ + ignoreDirectories: artifacts, .packages + + stages: + ############### BUILD STAGE ############### + ############### WINDOWS ############### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: $($(DncEngInternalBuildPool)) + image: windows.vs2022.amd64 + os: windows + emoji: 🪟 + helixTargetQueue: windows.amd64.vs2022.pre + oneESCompat: + templateFolderName: templates-official + publishTaskPrefix: 1ES. + runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) + # WORKAROUND: BinSkim requires the folder exist prior to scanning. + preSteps: + - powershell: New-Item -ItemType Directory -Path $(Build.SourcesDirectory)/artifacts/bin -Force + displayName: Create artifacts/bin directory + ${{ if and(eq(parameters.runTestBuild, false), ne(variables['Build.Reason'], 'PullRequest')) }}: + timeoutInMinutes: 180 + windowsJobParameterSets: + ### OFFICIAL ### + - categoryName: Official + publishArgument: $(_publishArgument) + signArgument: $(_signArgument) + officialBuildProperties: $(_officialBuildProperties) /p:BuildWorkloads=true + enableDefaultArtifacts: true + runTests: false + publishRetryConfig: true + variables: + _SignType: real + + ############### PACKAGE STAGE ############### + - ${{ if ne(variables['Build.Reason'], 'PullRequest') }}: + - stage: publish + displayName: Publish + dependsOn: [] + jobs: + - template: /eng/pipelines/templates/jobs/dnup-library-package.yml@self + parameters: + pool: + name: $(DncEngInternalBuildPool) + image: 1es-windows-2022 + os: windows diff --git a/.vsts-dnup-pr.yml b/.vsts-dnup-pr.yml new file mode 100644 index 000000000000..7b58f5f735c0 --- /dev/null +++ b/.vsts-dnup-pr.yml @@ -0,0 +1,72 @@ +# Pipeline: https://dev.azure.com/dnceng-public/public/_build?definitionId=323 + +trigger: none + +pr: + branches: + include: + - dnup + paths: + include: + - src/Installer/dnup/ + - test/dnup.Tests/ + - global.json + - .vsts-dnup-tests.yml + +parameters: +- name: enableArm64Job + displayName: Enables the ARM64 job + type: boolean + default: true + +variables: +- template: /eng/pipelines/templates/variables/sdk-defaults.yml + # Variables used: DncEngPublicBuildPool +- template: /eng/common/templates/variables/pool-providers.yml + +stages: +- stage: dnup + displayName: 🏰 dnup tests + jobs: + ############### WINDOWS ############### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals windows.vs2022.amd64.open + os: windows + emoji: 🪟 + helixTargetQueue: windows.amd64.vs2022.pre.open + + ############### LINUX ############### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: $(DncEngPublicBuildPool) + demands: ImageOverride -equals build.ubuntu.2204.amd64.open + os: linux + emoji: 🐧 + helixTargetQueue: ubuntu.2204.amd64.open + + ############### MACOS ############### + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + emoji: 🍎 + helixTargetQueue: osx.15.amd64.open + + ### ARM64 ### + - ${{ if eq(parameters.enableArm64Job, true) }}: + - template: /eng/pipelines/templates/jobs/dnup-tests.yml@self + parameters: + pool: + name: Azure Pipelines + vmImage: macOS-latest + os: macOS + emoji: 💪 + helixTargetQueue: osx.13.arm64.open + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 788923bf48d6..2fc29d442726 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -114,6 +114,7 @@ + diff --git a/dnup.slnf b/dnup.slnf new file mode 100644 index 000000000000..8384b13f95f0 --- /dev/null +++ b/dnup.slnf @@ -0,0 +1,11 @@ +{ + "solution": { + "path": "sdk.slnx", + "projects": [ + "src\\Installer\\dnup\\dnup.csproj", + "src\\Installer\\Microsoft.Dotnet.Installation\\Microsoft.Dotnet.Installation.csproj", + "test\\dnup.Tests\\dnup.Tests.csproj", + "src\\Resolvers\\Microsoft.DotNet.NativeWrapper\\Microsoft.DotNet.NativeWrapper.csproj + ] + } +} diff --git a/eng/pipelines/templates/jobs/dnup-library-package.yml b/eng/pipelines/templates/jobs/dnup-library-package.yml new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/eng/pipelines/templates/jobs/dnup-tests.yml b/eng/pipelines/templates/jobs/dnup-tests.yml new file mode 100644 index 000000000000..0477def2d581 --- /dev/null +++ b/eng/pipelines/templates/jobs/dnup-tests.yml @@ -0,0 +1,88 @@ +parameters: + ### GENERAL ### + variables: {} + dependsOn: '' + helixTargetQueue: '' + oneESCompat: + templateFolderName: templates + publishTaskPrefix: '' + container: '' + helixTargetContainer: '' + categoryName: dnup + runTests: true + publishRetryConfig: false + publishXunitResults: false + enableSbom: true + timeoutInMinutes: 150 + +jobs: +- template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/job/job.yml + parameters: + displayName: '${{ parameters.pool.emoji }} dnup tests: ${{ parameters.pool.os }} (${{ parameters.helixTargetQueue }})' + pool: ${{ parameters.pool }} + container: ${{ parameters.container }} + strategy: ${{ parameters.strategy }} + helixRepo: dotnet/sdk + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + enableMicrobuild: true + enablePublishBuildAssets: true + enableTelemetry: true + enablePublishUsingPipelines: true + enableSbom: ${{ parameters.enableSbom }} + variables: + - ${{ insert }}: ${{ parameters.variables }} + dependsOn: ${{ parameters.dependsOn }} + preSteps: ${{ parameters.preSteps }} + templateContext: + sdl: + binskim: + analyzeTargetGlob: +:f|eng\**\*.props;+:f|artifacts\bin\**\*.dll;+:f|artifacts\bin\**\*.exe;-:f|artifacts\bin\**\msdia140.dll;-:f|artifacts\bin\**\pgort140.dll;-:f|artifacts\bin\*Tests\**;-:f|**\Microsoft.NET.Runtime.Emscripten**\tools\**;-:f|**\CodeCoverage\**;-:f|artifacts\bin\**\capstone.dll; + + steps: + - ${{ if eq(parameters.pool.os, 'windows') }}: + - powershell: | + & .\restore.cmd + displayName: 🍱 Bootstrap toolset (Windows) + - powershell: | + & .\.dotnet\dotnet restore test\dnup.Tests\dnup.Tests.csproj + displayName: ♻️ Restore dnup tests (Windows) + - powershell: | + & .\.dotnet\dotnet build test\dnup.Tests\dnup.Tests.csproj -c Release --no-restore + displayName: 💻 Build Windows + - powershell: | + New-Item -Path "$(Build.SourcesDirectory)/artifacts/dnupTestResults" -ItemType Directory -Force + displayName: 📁 Create test results directory (Windows) + - powershell: | + & .\.dotnet\dotnet test test\dnup.Tests\dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: 🔍 Test Windows + - ${{ if ne(parameters.pool.os, 'windows') }}: + - script: | + ./restore.sh + displayName: 🍱 Bootstrap toolset (Unix) + - script: | + ./.dotnet/dotnet restore test/dnup.Tests/dnup.Tests.csproj + displayName: ♻️ Restore dnup tests (Unix) + - script: | + ./.dotnet/dotnet build test/dnup.Tests/dnup.Tests.csproj -c Release --no-restore + displayName: 🐧 Build (Unix) + - script: | + mkdir -p "$(Build.SourcesDirectory)/artifacts/dnupTestResults" + displayName: 📁 Create test results directory (Unix) + - script: | + ./.dotnet/dotnet test test/dnup.Tests/dnup.Tests.csproj -c Release --no-build --logger "trx;LogFileName=dnup-tests.trx" --results-directory $(Build.SourcesDirectory)/artifacts/dnupTestResults + displayName: 🔎 Test (Unix) + - task: PublishTestResults@2 + displayName: 🚀 Publish test results + condition: always() + inputs: + testResultsFormat: VSTest + testResultsFiles: '**/dnup-tests.trx' + searchFolder: $(Build.SourcesDirectory)/artifacts/dnupTestResults + testRunTitle: 'dnup ${{ parameters.pool.os }}' + - task: PublishBuildArtifacts@1 + displayName: ⬇️ Publish test artifacts + condition: always() + inputs: + PathtoPublish: $(Build.SourcesDirectory)/artifacts/dnupTestResults + ArtifactName: dnupTestResults_${{ parameters.pool.os }} + publishLocation: Container diff --git a/sdk.slnx b/sdk.slnx index 0246a040f447..76c8f2c407af 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -3,7 +3,6 @@ - @@ -88,6 +87,10 @@ + + + + @@ -309,6 +312,7 @@ + diff --git a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs index 5cd73f53abb8..5f1aab066131 100644 --- a/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs +++ b/src/Cli/dotnet/Telemetry/EnvironmentDetectionRule.cs @@ -33,7 +33,7 @@ public BooleanEnvironmentRule(params string[] variables) public override bool IsMatch() { - return _variables.Any(variable => + return _variables.Any(variable => bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value); } } @@ -96,8 +96,8 @@ public EnvironmentDetectionRuleWithResult(T result, params string[] variables) /// The result value if the rule matches; otherwise, null. public T? GetResult() { - return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) - ? _result + return _variables.Any(variable => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(variable))) + ? _result : null; } -} \ No newline at end of file +} diff --git a/src/Installer/.github/copilot-instructions.md b/src/Installer/.github/copilot-instructions.md new file mode 100644 index 000000000000..edc9547ec6c2 --- /dev/null +++ b/src/Installer/.github/copilot-instructions.md @@ -0,0 +1,8 @@ + +--- +applyTo: "**" +--- +- Environment: Windows 11 using PowerShell 7 +- Never use `&&` to chain commands; use semicolon (`;`) for PowerShell command chaining +- Prefer PowerShell cmdlets over external utilities when available +- Use PowerShell-style parameter syntax (-Parameter) rather than Unix-style flags diff --git a/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs new file mode 100644 index 000000000000..d90827d40301 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +[assembly: InternalsVisibleTo("dnup, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] +[assembly: InternalsVisibleTo("dnup.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs new file mode 100644 index 000000000000..0deeb807808d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallRoot.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; +public record DotnetInstallRoot( + string Path, + InstallArchitecture Architecture); diff --git a/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs new file mode 100644 index 000000000000..6afe6cc0756a --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DownloadProgress.cs @@ -0,0 +1,31 @@ +using System; + +namespace Microsoft.Dotnet.Installation +{ + /// + /// Represents download progress information. + /// + public readonly struct DownloadProgress + { + /// + /// Gets the number of bytes downloaded. + /// + public long BytesDownloaded { get; } + + /// + /// Gets the total number of bytes to download, if known. + /// + public long? TotalBytes { get; } + + /// + /// Gets the percentage of download completed, if total size is known. + /// + public double? PercentComplete => TotalBytes.HasValue ? (double)BytesDownloaded / TotalBytes.Value * 100 : null; + + public DownloadProgress(long bytesDownloaded, long? totalBytes) + { + BytesDownloaded = bytesDownloaded; + TotalBytes = totalBytes; + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs new file mode 100644 index 000000000000..64c1e859f6ce --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstallDiscoverer.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation; + +public interface IDotnetInstallDiscoverer +{ + DotnetInstallRoot GetDotnetInstallRootFromPath(); + + IEnumerable GetInstalledVersions(DotnetInstallRoot dotnetRoot, InstallComponent component); +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs new file mode 100644 index 000000000000..04eff3cf325a --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetInstaller.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation; + +public interface IDotnetInstaller +{ + void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version); + void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version); +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs new file mode 100644 index 000000000000..92571c2e4c8f --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/IDotnetReleaseInfoProvider.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation; + +public interface IDotnetReleaseInfoProvider +{ + IEnumerable GetAvailableChannels(); + + ReleaseVersion? GetLatestVersion(InstallComponent component, string channel); + + // Get all versions in a channel - do we have a scenario for this? + //IEnumerable GetAllVersions(InstallComponent component, string channel); + + SupportType GetSupportType(InstallComponent component, ReleaseVersion version); +} + +public enum SupportType +{ + OutOfSupport, + LongTermSupport, + StandardTermSupport +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs new file mode 100644 index 000000000000..ddc1b5b93d0e --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallArchitecture.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +public enum InstallArchitecture +{ + x86, + x64, + arm64 +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs new file mode 100644 index 000000000000..1cecbe90e59d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallComponent.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation; + +public enum InstallComponent +{ + SDK, + Runtime, + ASPNETCore, + WindowsDesktop +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs new file mode 100644 index 000000000000..a27a5c2ecdaf --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerFactory.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.Dotnet.Installation; + +public static class InstallerFactory +{ + public static IDotnetInstaller CreateInstaller() + { + return new DotnetInstaller(); + } + + public static IDotnetReleaseInfoProvider CreateReleaseInfoProvider() + { + return new DotnetReleaseInfoProvider(); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs new file mode 100644 index 000000000000..4d061d72c318 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/InstallerUtilities.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.Dotnet.Installation; + +public static class InstallerUtilities +{ + public static InstallArchitecture GetInstallArchitecture(System.Runtime.InteropServices.Architecture architecture) + { + return architecture switch + { + System.Runtime.InteropServices.Architecture.X86 => InstallArchitecture.x86, + System.Runtime.InteropServices.Architecture.X64 => InstallArchitecture.x64, + System.Runtime.InteropServices.Architecture.Arm64 => InstallArchitecture.arm64, + _ => throw new NotSupportedException($"Architecture {architecture} is not supported.") + }; + } + + public static InstallArchitecture GetDefaultInstallArchitecture() + { + return GetInstallArchitecture(RuntimeInformation.ProcessArchitecture); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs new file mode 100644 index 000000000000..83b29ea5d807 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ArchiveDotnetExtractor.cs @@ -0,0 +1,503 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal class ArchiveDotnetExtractor : IDisposable +{ + private readonly DotnetInstallRequest _request; + private readonly ReleaseVersion _resolvedVersion; + private readonly bool _noProgress; + private string scratchDownloadDirectory; + private string? _archivePath; + + public ArchiveDotnetExtractor(DotnetInstallRequest request, ReleaseVersion resolvedVersion, bool noProgress = false) + { + _request = request; + _resolvedVersion = resolvedVersion; + _noProgress = noProgress; + scratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; + } + + public void Prepare() + { + using var releaseManifest = new ReleaseManifest(); + var archiveName = $"dotnet-{Guid.NewGuid()}"; + _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DnupUtilities.GetArchiveFileExtensionForPlatform()); + + if (_noProgress) + { + // When no-progress is enabled, download without progress display + Console.WriteLine($"Downloading .NET SDK {_resolvedVersion}..."); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, null); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); + } + Console.WriteLine($"Download of .NET SDK {_resolvedVersion} complete."); + } + else + { + // Use progress display for normal operation + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var downloadTask = ctx.AddTask($"Downloading .NET SDK {_resolvedVersion}", autoStart: true); + var reporter = new SpectreDownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + var downloadSuccess = releaseManifest.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); + if (!downloadSuccess) + { + throw new InvalidOperationException($"Failed to download .NET archive for version {_resolvedVersion}"); + } + + downloadTask.Value = 100; + }); + } + } + + /** + Returns a string if the archive is valid within SDL specification, false otherwise. + */ + private void VerifyArchive(string archivePath) + { + if (!File.Exists(archivePath)) // Enhancement: replace this with actual verification logic once its implemented. + { + throw new InvalidOperationException("Archive verification failed."); + } + } + + + + internal static string ConstructArchiveName(string? versionString, string rid, string suffix) + { + // If version is not specified, use a generic name + if (string.IsNullOrEmpty(versionString)) + { + return $"dotnet-sdk-{rid}{suffix}"; + } + + // Make sure the version string doesn't have any build hash or prerelease identifiers + // This ensures compatibility with the official download URLs + string cleanVersion = versionString; + int dashIndex = versionString.IndexOf('-'); + if (dashIndex >= 0) + { + cleanVersion = versionString.Substring(0, dashIndex); + } + + return $"dotnet-sdk-{cleanVersion}-{rid}{suffix}"; + } + + + + public void Commit() + { + Commit(GetExistingSdkVersions(_request.InstallRoot)); + } + + public void Commit(IEnumerable existingSdkVersions) + { + if (_archivePath == null || !File.Exists(_archivePath)) + { + throw new InvalidOperationException("Archive not found. Make sure Prepare() was called successfully."); + } + + if (_noProgress) + { + // When no-progress is enabled, install without progress display + Console.WriteLine($"Installing .NET SDK {_resolvedVersion}..."); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, null); + if (extractResult is not null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + Console.WriteLine($"Installation of .NET SDK {_resolvedVersion} complete."); + } + else + { + // Use progress display for normal operation + Spectre.Console.AnsiConsole.Progress() + .Start(ctx => + { + var installTask = ctx.AddTask($"Installing .NET SDK {_resolvedVersion}", autoStart: true); + + // Extract archive directly to target directory with special handling for muxer + var extractResult = ExtractArchiveDirectlyToTarget(_archivePath, _request.InstallRoot.Path!, existingSdkVersions, installTask); + if (extractResult is not null) + { + throw new InvalidOperationException($"Failed to install SDK: {extractResult}"); + } + + installTask.Value = installTask.MaxValue; + }); + } + } + + /** + * Extracts the archive directly to the target directory with special handling for muxer. + * Combines extraction and installation into a single operation. + */ + private string? ExtractArchiveDirectlyToTarget(string archivePath, string targetDir, IEnumerable existingSdkVersions, Spectre.Console.ProgressTask? installTask) + { + try + { + // Ensure target directory exists + Directory.CreateDirectory(targetDir); + + var muxerConfig = ConfigureMuxerHandling(existingSdkVersions); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ExtractTarArchive(archivePath, targetDir, muxerConfig, installTask); + } + else + { + return ExtractZipArchive(archivePath, targetDir, muxerConfig, installTask); + } + } + catch (Exception e) + { + return e.Message; + } + } + + /** + * Configure muxer handling by determining if it needs to be updated. + */ + private MuxerHandlingConfig ConfigureMuxerHandling(IEnumerable existingSdkVersions) + { + ReleaseVersion? existingMuxerVersion = existingSdkVersions.Any() ? existingSdkVersions.Max() : (ReleaseVersion?)null; + ReleaseVersion newRuntimeVersion = _resolvedVersion; + bool shouldUpdateMuxer = existingMuxerVersion is null || newRuntimeVersion.CompareTo(existingMuxerVersion) > 0; + + string muxerName = DnupUtilities.GetDotnetExeName(); + string muxerTargetPath = Path.Combine(_request.InstallRoot.Path!, muxerName); + + return new MuxerHandlingConfig( + muxerName, + muxerTargetPath, + shouldUpdateMuxer); + } + + /** + * Extracts a tar or tar.gz archive to the target directory. + */ + private string? ExtractTarArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + string decompressedPath = DecompressTarGzIfNeeded(archivePath, out bool needsDecompression); + + try + { + // Count files in tar for progress reporting + long totalFiles = CountTarEntries(decompressedPath); + + // Set progress maximum + if (installTask is not null) + { + installTask.MaxValue = totalFiles > 0 ? totalFiles : 1; + } + + // Extract files directly to target + ExtractTarContents(decompressedPath, targetDir, muxerConfig, installTask); + + return null; + } + finally + { + // Clean up temporary decompressed file + if (needsDecompression && File.Exists(decompressedPath)) + { + File.Delete(decompressedPath); + } + } + } + + /** + * Decompresses a .tar.gz file if needed, returning the path to the tar file. + */ + private string DecompressTarGzIfNeeded(string archivePath, out bool needsDecompression) + { + needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + if (!needsDecompression) + { + return archivePath; + } + + string decompressedPath = Path.Combine( + Path.GetDirectoryName(archivePath) ?? Directory.CreateTempSubdirectory().FullName, + "decompressed.tar"); + + using FileStream originalFileStream = File.OpenRead(archivePath); + using FileStream decompressedFileStream = File.Create(decompressedPath); + using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); + decompressionStream.CopyTo(decompressedFileStream); + + return decompressedPath; + } + + /** + * Counts the number of entries in a tar file for progress reporting. + */ + private long CountTarEntries(string tarPath) + { + long totalFiles = 0; + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + while (tarReader.GetNextEntry() is not null) + { + totalFiles++; + } + return totalFiles; + } + + /** + * Extracts the contents of a tar file to the target directory. + */ + private void ExtractTarContents(string tarPath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + using var tarStream = File.OpenRead(tarPath); + var tarReader = new TarReader(tarStream); + TarEntry? entry; + + while ((entry = tarReader.GetNextEntry()) is not null) + { + if (entry.EntryType == TarEntryType.RegularFile) + { + ExtractTarFileEntry(entry, targetDir, muxerConfig, installTask); + } + else if (entry.EntryType == TarEntryType.Directory) + { + // Create directory if it doesn't exist + var dirPath = Path.Combine(targetDir, entry.Name); + Directory.CreateDirectory(dirPath); + installTask?.Increment(1); + } + else + { + // Skip other entry types + installTask?.Increment(1); + } + } + } + + /** + * Extracts a single file entry from a tar archive. + */ + private void ExtractTarFileEntry(TarEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + var fileName = Path.GetFileName(entry.Name); + var destPath = Path.Combine(targetDir, entry.Name); + + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromTar(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + using var outStream = File.Create(destPath); + entry.DataStream?.CopyTo(outStream); + } + + installTask?.Increment(1); + } + + /** + * Handles updating the muxer from a tar entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromTar(TarEntry entry, string muxerTargetPath) + { + // Create a temporary file for the muxer first to avoid locking issues + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + using (var outStream = File.Create(tempMuxerPath)) + { + entry.DataStream?.CopyTo(outStream); + } + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } + } + + /** + * Extracts a zip archive to the target directory. + */ + private string? ExtractZipArchive(string archivePath, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + long totalFiles = CountZipEntries(archivePath); + + installTask?.MaxValue = totalFiles > 0 ? totalFiles : 1; + + using var zip = ZipFile.OpenRead(archivePath); + foreach (var entry in zip.Entries) + { + ExtractZipEntry(entry, targetDir, muxerConfig, installTask); + } + + return null; + } + + /** + * Counts the number of entries in a zip file for progress reporting. + */ + private long CountZipEntries(string zipPath) + { + using var zip = ZipFile.OpenRead(zipPath); + return zip.Entries.Count; + } + + /** + * Extracts a single entry from a zip archive. + */ + private void ExtractZipEntry(ZipArchiveEntry entry, string targetDir, MuxerHandlingConfig muxerConfig, Spectre.Console.ProgressTask? installTask) + { + var fileName = Path.GetFileName(entry.FullName); + var destPath = Path.Combine(targetDir, entry.FullName); + + // Skip directories (we'll create them for files as needed) + if (string.IsNullOrEmpty(fileName)) + { + Directory.CreateDirectory(destPath); + installTask?.Increment(1); + return; + } + + // Special handling for dotnet executable (muxer) + if (string.Equals(fileName, muxerConfig.MuxerName, StringComparison.OrdinalIgnoreCase)) + { + if (muxerConfig.ShouldUpdateMuxer) + { + HandleMuxerUpdateFromZip(entry, muxerConfig.MuxerTargetPath); + } + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + entry.ExtractToFile(destPath, overwrite: true); + } + + installTask?.Increment(1); + } + + /** + * Handles updating the muxer from a zip entry, using a temporary file to avoid locking issues. + */ + private void HandleMuxerUpdateFromZip(ZipArchiveEntry entry, string muxerTargetPath) + { + var tempMuxerPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + entry.ExtractToFile(tempMuxerPath, overwrite: true); + + try + { + // Replace the muxer using the utility that handles locking + DnupUtilities.ForceReplaceFile(tempMuxerPath, muxerTargetPath); + } + finally + { + if (File.Exists(tempMuxerPath)) + { + File.Delete(tempMuxerPath); + } + } + } + + /** + * Configuration class for muxer handling. + */ + private readonly struct MuxerHandlingConfig + { + public string MuxerName { get; } + public string MuxerTargetPath { get; } + public bool ShouldUpdateMuxer { get; } + + public MuxerHandlingConfig(string muxerName, string muxerTargetPath, bool shouldUpdateMuxer) + { + MuxerName = muxerName; + MuxerTargetPath = muxerTargetPath; + ShouldUpdateMuxer = shouldUpdateMuxer; + } + } + + public void Dispose() + { + try + { + // Clean up temporary download directory + if (Directory.Exists(scratchDownloadDirectory)) + { + Directory.Delete(scratchDownloadDirectory, recursive: true); + } + } + catch + { + } + } + + // TODO: InstallerOrchestratorSingleton also checks existing installs via the manifest. Which should we use and where? + // This should be cached and more sophisticated based on vscode logic in the future + private IEnumerable GetExistingSdkVersions(DotnetInstallRoot installRoot) + { + if (installRoot.Path == null) + return Enumerable.Empty(); + + var dotnetExe = Path.Combine(installRoot.Path, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetExe)) + return Enumerable.Empty(); + + try + { + var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = dotnetExe; + process.StartInfo.Arguments = "--list-sdks"; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + var versions = new List(); + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(' '); + if (parts.Length > 0) + { + var versionStr = parts[0]; + if (ReleaseVersion.TryParse(versionStr, out var version)) + { + versions.Add(version); + } + } + } + return versions; + } + catch + { + return []; + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs new file mode 100644 index 000000000000..b36f2be9e1e6 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DnupUtilities.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal static class DnupUtilities +{ + public static string ExeSuffix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""; + + public static string GetDotnetExeName() + { + return "dotnet" + ExeSuffix; + } + + public static bool PathsEqual(string? a, string? b) + { + if (a == null && b == null) + { + return true; + } + else if (a == null || b == null) + { + return false; + } + + return string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + public static void ForceReplaceFile(string sourcePath, string destPath) + { + File.Copy(sourcePath, destPath, overwrite: true); + + // Copy file attributes + var srcInfo = new FileInfo(sourcePath); + var dstInfo = new FileInfo(destPath) + { + CreationTimeUtc = srcInfo.CreationTimeUtc, + LastWriteTimeUtc = srcInfo.LastWriteTimeUtc, + LastAccessTimeUtc = srcInfo.LastAccessTimeUtc, + Attributes = srcInfo.Attributes + }; + } + + public static string GetRuntimeIdentifier(InstallArchitecture architecture) + { + var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" : + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" : "unknown"; + + var arch = architecture switch + { + InstallArchitecture.x64 => "x64", + InstallArchitecture.x86 => "x86", + InstallArchitecture.arm64 => "arm64", + _ => "x64" // Default fallback + }; + + return $"{os}-{arch}"; + } + + public static string GetArchiveFileExtensionForPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ".zip"; // Windows typically uses zip archives + } + else + { + return ".tar.gz"; // Unix-like systems use tar.gz + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.cs new file mode 100644 index 000000000000..7e4a391c779a --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstall.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. + +using System; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.Dotnet.Installation.Internal; + +/// +/// Represents a .NET installation with a fully specified version. +/// The MuxerDirectory is the directory of the corresponding .NET host that has visibility into this .NET installation. +/// +internal record DotnetInstall( + DotnetInstallRoot InstallRoot, + ReleaseVersion Version, + InstallComponent Component); + +/// +/// Represents a request for a .NET installation with a channel version that will get resolved into a fully specified version. +/// +internal record DotnetInstallRequest( + DotnetInstallRoot InstallRoot, + UpdateChannel Channel, + InstallComponent Component, + InstallRequestOptions Options); + +internal record InstallRequestOptions() +{ + // Include options such as the custom feed, manifest path, etc. + public string? ManifestPath { get; init; } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs new file mode 100644 index 000000000000..3d08a84050d6 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetInstaller.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; +using Spectre.Console; + +namespace Microsoft.Dotnet.Installation.Internal +{ + internal class DotnetInstaller : IDotnetInstaller + { + public void Install(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version) + { + var installRequest = new DotnetInstallRequest(dotnetRoot, new UpdateChannel(version.ToString()), component, new InstallRequestOptions()); + + using ArchiveDotnetExtractor installer = new(installRequest, version, noProgress: true); + installer.Prepare(); + installer.Commit(); + } + public void Uninstall(DotnetInstallRoot dotnetRoot, InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs new file mode 100644 index 000000000000..168bcb95f063 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetReleaseInfoProvider.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal class DotnetReleaseInfoProvider : IDotnetReleaseInfoProvider +{ + public IEnumerable GetAvailableChannels() => throw new NotImplementedException(); + public ReleaseVersion? GetLatestVersion(InstallComponent component, string channel) + { + var releaseManifest = new ReleaseManifest(); + var release = releaseManifest.GetLatestVersionForChannel(new UpdateChannel(channel), component); + + return release; + } + public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs new file mode 100644 index 000000000000..243e49da0766 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -0,0 +1,576 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.Dotnet.Installation.Internal; + +/// +/// Handles downloading and parsing .NET release manifests to find the correct installer/archive for a given installation. +/// +internal class ReleaseManifest(HttpClient httpClient) : IDisposable +{ + /// + /// Parses a version channel string into its components. + /// + /// Channel string to parse (e.g., "9", "9.0", "9.0.1xx", "9.0.103") + /// Tuple containing (major, minor, featureBand, isFullySpecified) + private (int Major, int Minor, string? FeatureBand, bool IsFullySpecified) ParseVersionChannel(UpdateChannel channel) + { + var parts = channel.Name.Split('.'); + int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : -1; + int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : -1; + + // Check if we have a feature band (like 1xx) or a fully specified patch + string? featureBand = null; + bool isFullySpecified = false; + + if (parts.Length >= 3) + { + if (parts[2].EndsWith("xx")) + { + // Feature band pattern (e.g., "1xx") + featureBand = parts[2].Substring(0, parts[2].Length - 2); + } + else if (int.TryParse(parts[2], out _)) + { + // Fully specified version (e.g., "9.0.103") + isFullySpecified = true; + } + } + + return (major, minor, featureBand, isFullySpecified); + } + + /// + /// Finds the latest fully specified version for a given channel string (major, major.minor, or feature band). + /// + /// Channel string (e.g., "9", "9.0", "9.0.1xx", "9.0.103", "lts", "sts", "preview") + /// InstallMode.SDK or InstallMode.Runtime + /// Latest fully specified version string, or null if not found + public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) + { + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) + { + var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestVersionByReleaseType(productIndex, releaseType, component); + } + else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) + { + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestPreviewVersion(productIndex, component); + } + else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) + { + var productIndex = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return GetLatestActiveVersion(productIndex, component); + } + + var (major, minor, featureBand, isFullySpecified) = ParseVersionChannel(channel); + + // If major is invalid, return null + if (major < 0) + { + return null; + } + + // If the version is already fully specified, just return it as-is + if (isFullySpecified) + { + return new ReleaseVersion(channel.Name); + } + + // Load the index manifest + var index = ProductCollection.GetAsync().GetAwaiter().GetResult(); + if (minor < 0) + { + return GetLatestVersionForMajorOrMajorMinor(index, major, component); // Major Only (e.g., "9") + } + else if (minor >= 0 && featureBand == null) // Major.Minor (e.g., "9.0") + { + return GetLatestVersionForMajorOrMajorMinor(index, major, component, minor); + } + else if (minor >= 0 && featureBand is not null) // Not Fully Qualified Feature band Version (e.g., "9.0.1xx") + { + return GetLatestVersionForFeatureBand(index, major, minor, featureBand, component); + } + + return null; + } + + private IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null) + { + var validProducts = index.Where(p => p.ProductVersion.StartsWith(minor is not null ? $"{major}.{minor}" : $"{major}.")); + return validProducts; + } + + /// + /// Gets the latest version for a major-only channel (e.g., "9"). + /// + private ReleaseVersion? GetLatestVersionForMajorOrMajorMinor(IEnumerable index, int major, InstallComponent component, int? minor = null) + { + // Assumption: The manifest is designed so that the first product for a major version will always be latest. + Product? latestProductWithMajor = GetProductsInMajorOrMajorMinor(index, major, minor).FirstOrDefault(); + return GetLatestReleaseVersionInProduct(latestProductWithMajor, component); + } + + /// + /// Gets the latest version based on support status (LTS or STS). + /// + /// The product collection to search + /// True for LTS (Long-Term Support), false for STS (Standard-Term Support) + /// InstallComponent.SDK or InstallComponent.Runtime + /// Latest stable version string matching the support status, or null if none found + private static ReleaseVersion? GetLatestVersionByReleaseType(IEnumerable index, ReleaseType releaseType, InstallComponent component) + { + var correctPhaseProducts = index?.Where(p => p.ReleaseType == releaseType) ?? Enumerable.Empty(); + return GetLatestActiveVersion(correctPhaseProducts, component); + } + + /// + /// Gets the latest preview version available. + /// + /// The product collection to search + /// InstallComponent.SDK or InstallComponent.Runtime + /// Latest preview or GoLive version string, or null if none found + private ReleaseVersion? GetLatestPreviewVersion(IEnumerable index, InstallComponent component) + { + ReleaseVersion? latestPreviewVersion = GetLatestVersionBySupportPhase(index, component, [SupportPhase.Preview, SupportPhase.GoLive]); + if (latestPreviewVersion is not null) + { + return latestPreviewVersion; + } + + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); + } + + /// + /// Gets the latest version across all available products that matches the support phase. + /// + private static ReleaseVersion? GetLatestActiveVersion(IEnumerable index, InstallComponent component) + { + return GetLatestVersionBySupportPhase(index, component, [SupportPhase.Active]); + } + /// + /// Gets the latest version across all available products that matches the support phase. + /// + private static ReleaseVersion? GetLatestVersionBySupportPhase(IEnumerable index, InstallComponent component, SupportPhase[] acceptedSupportPhases) + { + // A version in preview/ga/rtm support is considered Go Live and not Active. + var activeSupportProducts = index?.Where(p => acceptedSupportPhases.Contains(p.SupportPhase)); + + // The manifest is designed so that the first product will always be latest. + Product? latestActiveSupportProduct = activeSupportProducts?.FirstOrDefault(); + + return GetLatestReleaseVersionInProduct(latestActiveSupportProduct, component); + } + + private static ReleaseVersion? GetLatestReleaseVersionInProduct(Product? product, InstallComponent component) + { + // Assumption: The latest runtime version will always be the same across runtime components. + ReleaseVersion? latestVersion = component switch + { + InstallComponent.SDK => product?.LatestSdkVersion, + _ => product?.LatestRuntimeVersion + }; + + return latestVersion; + } + + /// + /// Replaces user input feature band strings into the full feature band. + /// This would convert '1xx' into '100'. + /// 100 is not necessarily the latest but it is the feature band. + /// The other number in the band is the patch. + /// + /// + /// + private static int NormalizeFeatureBandInput(string band) + { + var bandString = band + .Replace("X", "x") + .Replace("x", "0") + .PadRight(3, '0') + .Substring(0, 3); + return int.Parse(bandString); + } + + + /// + /// Gets the latest version for a feature band channel (e.g., "9.0.1xx"). + /// + private ReleaseVersion? GetLatestVersionForFeatureBand(ProductCollection index, int major, int minor, string featureBand, InstallComponent component) + { + if (component != InstallComponent.SDK) + { + return null; + } + + var validProducts = GetProductsInMajorOrMajorMinor(index, major, minor); + var latestProduct = validProducts.FirstOrDefault(); + var releases = latestProduct?.GetReleasesAsync().GetAwaiter().GetResult().ToList() ?? new List(); + var normalizedFeatureBand = NormalizeFeatureBandInput(featureBand); + + foreach (var release in releases) + { + foreach (var sdk in release.Sdks) + { + if (sdk.Version.SdkFeatureBand == normalizedFeatureBand) + { + return sdk.Version; + } + } + } + + return null; + } + + private const int MaxRetryCount = 3; + private const int RetryDelayMilliseconds = 1000; + + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private ProductCollection? _productCollection; + + public ReleaseManifest() + : this(CreateDefaultHttpClient()) + { + } + + /// + /// Creates an HttpClient with enhanced proxy support for enterprise environments. + /// + private static HttpClient CreateDefaultHttpClient() + { + var handler = new HttpClientHandler() + { + UseProxy = true, + UseDefaultCredentials = true, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + + var client = new HttpClient(handler) + { + Timeout = TimeSpan.FromMinutes(10) + }; + + // Set user-agent to identify dnup in telemetry + client.DefaultRequestHeaders.UserAgent.ParseAdd("dnup-dotnet-installer"); + + return client; + } + + /// + /// Downloads the archive from the specified URL to the destination path with progress reporting. + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + protected async Task DownloadArchiveAsync(string downloadUrl, string destinationPath, IProgress? progress = null) + { + // Create temp file path in same directory for atomic move when complete + string tempPath = $"{destinationPath}.download"; + + for (int attempt = 1; attempt <= MaxRetryCount; attempt++) + { + try + { + // Ensure the directory exists + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + // Try to get content length for progress reporting + long? totalBytes = await GetContentLengthAsync(downloadUrl); + + // Make the actual download request + using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + // Get the total bytes if we didn't get it before + if (!totalBytes.HasValue && response.Content.Headers.ContentLength.HasValue) + { + totalBytes = response.Content.Headers.ContentLength.Value; + } + + using var contentStream = await response.Content.ReadAsStreamAsync(); + using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[81920]; // 80KB buffer + long bytesRead = 0; + int read; + + var lastProgressReport = DateTime.UtcNow; + + while ((read = await contentStream.ReadAsync(buffer)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read)); + + bytesRead += read; + + // Report progress at most every 100ms to avoid UI thrashing + var now = DateTime.UtcNow; + if ((now - lastProgressReport).TotalMilliseconds > 100) + { + lastProgressReport = now; + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + } + } + + // Final progress report + progress?.Report(new DownloadProgress(bytesRead, totalBytes)); + + // Ensure all data is written to disk + await fileStream.FlushAsync(); + fileStream.Close(); + + // Atomic move to final destination + if (File.Exists(destinationPath)) + { + File.Delete(destinationPath); + } + File.Move(tempPath, destinationPath); + + return true; + } + catch (Exception) + { + // Delete the partial download if it exists + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Ignore cleanup errors + } + + if (attempt < MaxRetryCount) + { + await Task.Delay(RetryDelayMilliseconds * attempt); // Exponential backoff + } + else + { + return false; + } + } + } + + return false; + } + + /// + /// Gets the content length of a resource. + /// + private async Task GetContentLengthAsync(string url) + { + try + { + using var headRequest = new HttpRequestMessage(HttpMethod.Head, url); + using var headResponse = await _httpClient.SendAsync(headRequest); + return headResponse.Content.Headers.ContentLength; + } + catch + { + return null; + } + } + + /// + /// Downloads the archive from the specified URL to the destination path (synchronous version). + /// + /// The URL to download from + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download was successful, false otherwise + protected bool DownloadArchive(string downloadUrl, string destinationPath, IProgress? progress = null) + { + return DownloadArchiveAsync(downloadUrl, destinationPath, progress).GetAwaiter().GetResult(); + } + + /// + /// Downloads the archive for the specified installation and verifies its hash. + /// + /// The .NET installation details + /// The local path to save the downloaded file + /// Optional progress reporting + /// True if download and verification were successful, false otherwise + public bool DownloadArchiveWithVerification(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) + { + var targetFile = FindReleaseFile(installRequest, resolvedVersion); + string? downloadUrl = targetFile?.Address.ToString(); + string? expectedHash = targetFile?.Hash.ToString(); + + if (string.IsNullOrEmpty(expectedHash) || string.IsNullOrEmpty(downloadUrl)) + { + return false; + } + + if (!DownloadArchive(downloadUrl, destinationPath, progress)) + { + return false; + } + + return VerifyFileHash(destinationPath, expectedHash); + } + + /// + /// Finds the appropriate release file for the given installation. + /// + /// The .NET installation details + /// The matching ReleaseFile, throws if none are available. + private ReleaseFile? FindReleaseFile(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + try + { + var productCollection = GetProductCollection(); + var product = FindProduct(productCollection, resolvedVersion) ?? throw new InvalidOperationException($"No product found for version {resolvedVersion}"); + var release = FindRelease(product, resolvedVersion, installRequest.Component) ?? throw new InvalidOperationException($"No release found for version {resolvedVersion}"); + return FindMatchingFile(release, installRequest, resolvedVersion); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to find an available release for install {installRequest} : ${ex.Message}", ex); + } + } + + /// + /// Gets or loads the ProductCollection with caching. + /// + private ProductCollection GetProductCollection() + { + if (_productCollection is not null) + { + return _productCollection; + } + + _productCollection = ProductCollection.GetAsync().GetAwaiter().GetResult(); + return _productCollection; + } + + /// + /// Finds the product for the given version. + /// + private static Product? FindProduct(ProductCollection productCollection, ReleaseVersion releaseVersion) + { + var majorMinor = $"{releaseVersion.Major}.{releaseVersion.Minor}"; + return productCollection.FirstOrDefault(p => p.ProductVersion == majorMinor); + } + + /// + /// Finds the specific release for the given version. + /// + private static ReleaseComponent? FindRelease(Product product, ReleaseVersion resolvedVersion, InstallComponent component) + { + var releases = product.GetReleasesAsync().GetAwaiter().GetResult().ToList(); + + foreach (var release in releases) + { + if (component == InstallComponent.SDK) + { + foreach (var sdk in release.Sdks) + { + if (sdk.Version.Equals(resolvedVersion)) + { + return sdk; + } + } + } + else + { + var runtimesQuery = component switch + { + InstallComponent.ASPNETCore => release.Runtimes + .Where(r => r.Name.Contains("ASP", StringComparison.OrdinalIgnoreCase)), + InstallComponent.WindowsDesktop => release.Runtimes + .Where(r => r.Name.Contains("Desktop", StringComparison.OrdinalIgnoreCase)), + _ => release.Runtimes + .Where(r => r.Name.Contains(".NET Runtime", StringComparison.OrdinalIgnoreCase) || + r.Name.Contains(".NET Core Runtime", StringComparison.OrdinalIgnoreCase)), + }; + foreach (var runtime in runtimesQuery) + { + if (runtime.Version.Equals(resolvedVersion)) + { + return runtime; + } + } + } + } + + return null; + } + + /// + /// Finds the matching file in the release for the given installation requirements. + /// + private static ReleaseFile? FindMatchingFile(ReleaseComponent release, DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion) + { + var rid = DnupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture); + var fileExtension = DnupUtilities.GetArchiveFileExtensionForPlatform(); + + var matchingFiles = release.Files + .Where(f => f.Rid == rid) // TODO: Do we support musl here? + .Where(f => f.Name.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingFiles.Count == 0) + { + return null; + } + + return matchingFiles.First(); + } + + /// + /// Computes the SHA512 hash of a file. + /// + /// Path to the file to hash + /// The hash as a lowercase hex string + public static string ComputeFileHash(string filePath) + { + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + // TODO: Older runtime versions use a different SHA algorithm. + // Eventually the manifest should indicate which algorithm to use. + using var sha512 = SHA512.Create(); + byte[] hashBytes = sha512.ComputeHash(fileStream); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Verifies that a downloaded file matches the expected hash. + /// + /// Path to the file to verify + /// Expected hash value + /// True if the hash matches, false otherwise + public static bool VerifyFileHash(string filePath, string expectedHash) + { + if (string.IsNullOrEmpty(expectedHash)) + { + return false; + } + + string actualHash = ComputeFileHash(filePath); + return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs new file mode 100644 index 000000000000..e7194e234736 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; + +namespace Microsoft.Dotnet.Installation.Internal; + +public class ScopedMutex : IDisposable +{ + private readonly Mutex _mutex; + private bool _hasHandle; + // Track recursive holds on a per-thread basis so we can assert manifest access without re-acquiring. + private static readonly ThreadLocal _holdCount = new(() => 0); + + public ScopedMutex(string name) + { + try + { + // On Linux and Mac, "Global\" prefix doesn't work - strip it if present + string mutexName = name; + if (Environment.OSVersion.Platform != PlatformID.Win32NT && mutexName.StartsWith("Global\\")) + { + mutexName = mutexName.Substring(7); + } + + _mutex = new Mutex(false, mutexName); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(300), false); + if (_hasHandle) + { + _holdCount.Value = _holdCount.Value + 1; + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not create or acquire mutex '{name}': {ex.Message}"); + throw; + } + } + + public bool HasHandle => _hasHandle; + public static bool CurrentThreadHoldsMutex => _holdCount.Value > 0; + + public void Dispose() + { + if (_hasHandle) + { + try + { + _mutex.ReleaseMutex(); + } + finally + { + // Decrement hold count even if release throws. + if (_holdCount.Value > 0) + { + _holdCount.Value = _holdCount.Value - 1; + } + } + } + _mutex.Dispose(); + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs new file mode 100644 index 000000000000..9a0fa920ad5d --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/SpectreDownloadProgressReporter.cs @@ -0,0 +1,46 @@ +using System; +using Spectre.Console; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.Dotnet.Installation.Internal +{ + public class SpectreDownloadProgressReporter : IProgress + { + private readonly ProgressTask _task; + private readonly string _description; + private long? _totalBytes; + + public SpectreDownloadProgressReporter(ProgressTask task, string description) + { + _task = task; + _description = description; + } + + public void Report(DownloadProgress value) + { + if (value.TotalBytes.HasValue) + { + _totalBytes = value.TotalBytes; + } + if (_totalBytes.HasValue && _totalBytes.Value > 0) + { + double percent = (double)value.BytesDownloaded / _totalBytes.Value * 100.0; + _task.Value = percent; + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)} / {FormatBytes(_totalBytes.Value)})"; + } + else + { + _task.Description = $"{_description} ({FormatBytes(value.BytesDownloaded)})"; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes > 1024 * 1024) + return $"{bytes / (1024 * 1024)} MB"; + if (bytes > 1024) + return $"{bytes / 1024} KB"; + return $"{bytes} B"; + } + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs new file mode 100644 index 000000000000..6c66d0a58d4e --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/UpdateChannel.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +internal class UpdateChannel +{ + public string Name { get; set; } + + public UpdateChannel(string name) + { + Name = name; + } + + public bool IsFullySpecifiedVersion() + { + return ReleaseVersion.TryParse(Name, out _); + } + +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj new file mode 100644 index 000000000000..ec1119864671 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Microsoft.Dotnet.Installation.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + + + $(NoWarn);CS8002 + true + true + Microsoft.Dotnet.Installation + false + .NET Installation Library + 1.0.0-alpha + $(Version) + + + + + + + + + diff --git a/src/Installer/dnup/.gitignore b/src/Installer/dnup/.gitignore new file mode 100644 index 000000000000..98ad9206b3e8 --- /dev/null +++ b/src/Installer/dnup/.gitignore @@ -0,0 +1,2 @@ +# Override the root .gitignore to NOT ignore the .vscode folder +!.vscode/ diff --git a/src/Installer/dnup/.vscode/launch.json b/src/Installer/dnup/.vscode/launch.json new file mode 100644 index 000000000000..24246299b009 --- /dev/null +++ b/src/Installer/dnup/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": "${input:commandLineArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "requireExactSource": false + } + ], + "inputs": [ + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/.vscode/settings.json b/src/Installer/dnup/.vscode/settings.json new file mode 100644 index 000000000000..c9127932ea34 --- /dev/null +++ b/src/Installer/dnup/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "dotnet.defaultSolution": "dnup.csproj", + "csharp.debug.console": "externalTerminal", + "editor.formatOnSave": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true, + "dotnetAcquisitionExtension.existingDotnetPath": [ + { + "extensionId": "ms-dotnettools.csharp", + "path": "C:\\Program Files\\dotnet\\dotnet.exe" + } + ], + "launch": { + "configurations": [], + "compounds": [] + }, + "omnisharp.defaultLaunchSolution": "dnup.csproj" +} \ No newline at end of file diff --git a/src/Installer/dnup/.vscode/tasks.json b/src/Installer/dnup/.vscode/tasks.json new file mode 100644 index 000000000000..541939d1dae4 --- /dev/null +++ b/src/Installer/dnup/.vscode/tasks.json @@ -0,0 +1,56 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/dnup.csproj", + "${input:buildArgs}" + ], + "problemMatcher": "$msCompile" + } + ], + "inputs": [ + { + "id": "buildArgs", + "type": "promptString", + "description": "Additional build arguments", + "default": "" + } + ] +} \ No newline at end of file diff --git a/src/Installer/dnup/ArchiveInstallationValidator.cs b/src/Installer/dnup/ArchiveInstallationValidator.cs new file mode 100644 index 000000000000..f6909756300d --- /dev/null +++ b/src/Installer/dnup/ArchiveInstallationValidator.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.Dotnet.Installation.Internal; +using System.Runtime.InteropServices; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.NativeWrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ArchiveInstallationValidator : IInstallationValidator +{ + private const string HostFxrRuntimeProperty = "HOSTFXR_PATH"; + private static readonly Dictionary RuntimeMonikerByComponent = new() + { + [InstallComponent.Runtime] = "Microsoft.NETCore.App", + [InstallComponent.ASPNETCore] = "Microsoft.AspNetCore.App", + [InstallComponent.WindowsDesktop] = "Microsoft.WindowsDesktop.App" + }; + + public bool Validate(DotnetInstall install) + { + string? installRoot = install.InstallRoot.Path; + if (string.IsNullOrEmpty(installRoot)) + { + return false; + } + + string dotnetMuxerPath = Path.Combine(installRoot, DnupUtilities.GetDotnetExeName()); + if (!File.Exists(dotnetMuxerPath)) + { + return false; + } + + string resolvedVersion = install.Version.ToString(); + if (!ValidateComponentLayout(installRoot, resolvedVersion, install.Component)) + { + return false; + } + + if (!ValidateWithHostFxr(installRoot, install.Version, install.Component)) + { + return false; + } + + // We should also validate whether the host is the maximum version or higher than all installed versions. + + return true; + } + + private static bool ValidateComponentLayout(string installRoot, string resolvedVersion, InstallComponent component) + { + if (component == InstallComponent.SDK) + { + string sdkDirectory = Path.Combine(installRoot, "sdk", resolvedVersion); + return Directory.Exists(sdkDirectory); + } + + if (RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + string runtimeDirectory = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion); + return Directory.Exists(runtimeDirectory); + } + + return false; + } + + private bool ValidateWithHostFxr(string installRoot, ReleaseVersion resolvedVersion, InstallComponent component) + { + try + { + ConfigureHostFxrResolution(installRoot); + + var bundleProvider = new NETBundlesNativeWrapper(); + NetEnvironmentInfo environmentInfo = bundleProvider.GetDotnetEnvironmentInfo(installRoot); + + if (component == InstallComponent.SDK) + { + string expectedPath = Path.Combine(installRoot, "sdk", resolvedVersion.ToString()); + return environmentInfo.SdkInfo.Any(sdk => + string.Equals(sdk.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(sdk.Path, expectedPath)); + } + + if (!RuntimeMonikerByComponent.TryGetValue(component, out string? runtimeMoniker)) + { + return false; + } + + string expectedRuntimePath = Path.Combine(installRoot, "shared", runtimeMoniker, resolvedVersion.ToString()); + return environmentInfo.RuntimeInfo.Any(runtime => + string.Equals(runtime.Name, runtimeMoniker, StringComparison.OrdinalIgnoreCase) && + string.Equals(runtime.Version.ToString(), resolvedVersion.ToString(), StringComparison.OrdinalIgnoreCase) && + DnupUtilities.PathsEqual(runtime.Path, expectedRuntimePath)); + } + catch + { + return false; + } + } + + private static void ConfigureHostFxrResolution(string installRoot) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + if (AppContext.GetData(HostFxrRuntimeProperty) is not null) + { + return; + } + + string? hostFxrPath = FindHostFxrLibrary(installRoot); + if (hostFxrPath is not null) + { + AppContext.SetData(HostFxrRuntimeProperty, hostFxrPath); + } + } + + private static string? FindHostFxrLibrary(string installRoot) + { + string hostFxrDirectory = Path.Combine(installRoot, "host", "fxr"); + if (!Directory.Exists(hostFxrDirectory)) + { + return null; + } + + string libraryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "hostfxr.dll" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "libhostfxr.dylib" + : "libhostfxr.so"; + + return Directory.EnumerateFiles(hostFxrDirectory, libraryName, SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } +} diff --git a/src/Installer/dnup/CommandBase.cs b/src/Installer/dnup/CommandBase.cs new file mode 100644 index 000000000000..e1b27c8efc9f --- /dev/null +++ b/src/Installer/dnup/CommandBase.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public abstract class CommandBase +{ + protected ParseResult _parseResult; + + protected CommandBase(ParseResult parseResult) + { + _parseResult = parseResult; + //ShowHelpOrErrorIfAppropriate(parseResult); + } + + //protected CommandBase() { } + + //protected virtual void ShowHelpOrErrorIfAppropriate(ParseResult parseResult) + //{ + // parseResult.ShowHelpOrErrorIfAppropriate(); + //} + + public abstract int Execute(); +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs new file mode 100644 index 000000000000..37fc7435c741 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockDotnetInstaller.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Spectre.Console; + +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockDotnetInstaller : IDotnetInstallManager + { + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + return new GlobalJsonInfo + { + GlobalJsonPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_PATH"), + GlobalJsonContents = null // Set to null for test mock; update as needed for tests + }; + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + public DotnetInstallRootConfiguration? GetConfiguredInstallType() + { + var testHookDefaultInstall = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL"); + InstallType installtype; + if (!Enum.TryParse(testHookDefaultInstall, out installtype)) + { + return null; + } + var installPath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_CURRENT_INSTALL_PATH") ?? GetDefaultDotnetInstallPath(); + return new(new(installPath, InstallerUtilities.GetDefaultInstallArchitecture()), installtype, true, true); + } + + public string? GetLatestInstalledAdminVersion() + { + var latestAdminVersion = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_LATEST_ADMIN_VERSION"); + if (string.IsNullOrEmpty(latestAdminVersion)) + { + latestAdminVersion = "10.0.0-preview.7"; + } + return latestAdminVersion; + } + + public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + using (var httpClient = new HttpClient()) + { + List downloads = sdkVersions.Select(version => + { + string downloadLink = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.303/dotnet-sdk-9.0.303-win-x64.exe"; + var task = progressContext.AddTask($"Downloading .NET SDK {version}"); + return (Action)(() => + { + Download(downloadLink, httpClient, task); + }); + }).ToList(); + + foreach (var download in downloads) + { + download(); + } + } + } + + void Download(string url, HttpClient httpClient, ProgressTask task) + { + for (int i = 0; i < 100; i++) + { + task.Increment(1); + Thread.Sleep(20); // Simulate some work + } + task.Value = 100; + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) + { + SpectreAnsiConsole.WriteLine($"Updating {globalJsonPath} to SDK version {sdkVersion} (AllowPrerelease={allowPrerelease}, RollForward={rollForward})"); + } + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) + { + SpectreAnsiConsole.WriteLine($"Configuring install type to {installType} (dotnetRoot={dotnetRoot})"); + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs new file mode 100644 index 000000000000..78a2b9a819dd --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/EnvironmentVariableMockReleaseInfoProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install +{ + internal class EnvironmentVariableMockReleaseInfoProvider : IDotnetReleaseInfoProvider + { + IEnumerable IDotnetReleaseInfoProvider.GetAvailableChannels() + { + var channels = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_AVAILABLE_CHANNELS"); + if (string.IsNullOrEmpty(channels)) + { + return new List { "latest", "preview", "10", "10.0.1xx", "10.0.2xx", "9", "9.0.3xx", "9.0.2xx", "9.0.1xx" }; + } + return channels.Split(',').ToList(); + } + public ReleaseVersion GetLatestVersion(InstallComponent component, string channel) + { + if (component != InstallComponent.SDK) + { + throw new NotImplementedException("Only SDK component is supported in this mock provider"); + } + + string version; + if (channel == "preview") + { + version = "11.0.100-preview.1.42424"; + } + else if (channel == "latest" || channel == "10" || channel == "10.0.2xx") + { + version = "10.0.0-preview.7"; + } + else if (channel == "10.0.1xx") + { + version = "10.0.106"; + } + else if (channel == "9" || channel == "9.0.3xx") + { + version = "9.0.309"; + } + else if (channel == "9.0.2xx") + { + version = "9.0.212"; + } + else if (channel == "9.0.1xx") + { + version = "9.0.115"; + } + + version = channel; + + return new ReleaseVersion(version); + } + + public SupportType GetSupportType(InstallComponent component, ReleaseVersion version) => throw new NotImplementedException(); + + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs new file mode 100644 index 000000000000..e1cb04ec401d --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -0,0 +1,277 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Net.Http; +using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Spectre.Console; +using SpectreAnsiConsole = Spectre.Console.AnsiConsole; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +internal class SdkInstallCommand(ParseResult result) : CommandBase(result) +{ + private readonly string? _versionOrChannel = result.GetValue(SdkInstallCommandParser.ChannelArgument); + private readonly string? _installPath = result.GetValue(SdkInstallCommandParser.InstallPathOption); + private readonly bool? _setDefaultInstall = result.GetValue(SdkInstallCommandParser.SetDefaultInstallOption); + private readonly bool? _updateGlobalJson = result.GetValue(SdkInstallCommandParser.UpdateGlobalJsonOption); + private readonly string? _manifestPath = result.GetValue(SdkInstallCommandParser.ManifestPathOption); + private readonly bool _interactive = result.GetValue(SdkInstallCommandParser.InteractiveOption); + private readonly bool _noProgress = result.GetValue(SdkInstallCommandParser.NoProgressOption); + + private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager(); + private readonly IDotnetReleaseInfoProvider _releaseInfoProvider = new EnvironmentVariableMockReleaseInfoProvider(); + private readonly ManifestChannelVersionResolver _channelVersionResolver = new ManifestChannelVersionResolver(); + + public override int Execute() + { + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); + + var currentDotnetInstallRoot = _dotnetInstaller.GetConfiguredInstallType(); + + string? resolvedInstallPath = null; + + string? installPathFromGlobalJson = null; + if (globalJsonInfo?.GlobalJsonPath is not null) + { + installPathFromGlobalJson = globalJsonInfo.SdkPath; + + if (installPathFromGlobalJson is not null && _installPath is not null && + !DnupUtilities.PathsEqual(installPathFromGlobalJson, _installPath)) + { + // TODO: Add parameter to override error + Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + return 1; + } + + resolvedInstallPath = installPathFromGlobalJson; + } + + if (resolvedInstallPath == null) + { + resolvedInstallPath = _installPath; + } + + if (resolvedInstallPath == null && currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) + { + // If a user installation is already set up, we don't need to prompt for the install path + resolvedInstallPath = currentDotnetInstallRoot.Path; + } + + if (resolvedInstallPath == null) + { + if (_interactive) + { + resolvedInstallPath = SpectreAnsiConsole.Prompt( + new TextPrompt("Where should we install the .NET SDK to?)") + .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); + } + else + { + // If no install path is specified, use the default install path + resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + } + } + + string? channelFromGlobalJson = null; + if (globalJsonInfo?.GlobalJsonPath is not null) + { + channelFromGlobalJson = ResolveChannelFromGlobalJson(globalJsonInfo.GlobalJsonPath); + } + + bool? resolvedUpdateGlobalJson = null; + + if (channelFromGlobalJson is not null && _versionOrChannel is not null && + // TODO: Should channel comparison be case-sensitive? + !channelFromGlobalJson.Equals(_versionOrChannel, StringComparison.OrdinalIgnoreCase)) + { + if (_interactive && _updateGlobalJson == null) + { + resolvedUpdateGlobalJson = SpectreAnsiConsole.Confirm( + $"The channel specified in global.json ({channelFromGlobalJson}) does not match the channel specified ({_versionOrChannel}). Do you want to update global.json to match the specified channel?", + defaultValue: true); + } + } + + string? resolvedChannel = null; + + if (channelFromGlobalJson is not null) + { + SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); + + resolvedChannel = channelFromGlobalJson; + } + else if (_versionOrChannel is not null) + { + resolvedChannel = _versionOrChannel; + } + else + { + if (_interactive) + { + + SpectreAnsiConsole.WriteLine("Available supported channels: " + string.Join(' ', _releaseInfoProvider.GetAvailableChannels())); + SpectreAnsiConsole.WriteLine("You can also specify a specific version (for example 9.0.304)."); + + resolvedChannel = SpectreAnsiConsole.Prompt( + new TextPrompt("Which channel of the .NET SDK do you want to install?") + .DefaultValue("latest")); + } + else + { + resolvedChannel = "latest"; // Default to latest if no channel is specified + } + } + + bool? resolvedSetDefaultInstall = _setDefaultInstall; + + if (resolvedSetDefaultInstall == null) + { + // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) + if (_interactive && installPathFromGlobalJson == null) + { + if (currentDotnetInstallRoot == null) + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + else if (currentDotnetInstallRoot.InstallType == InstallType.User) + { + if (DnupUtilities.PathsEqual(resolvedInstallPath, currentDotnetInstallRoot.Path)) + { + // No need to prompt here, the default install is already set up. + } + else + { + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"The default dotnet install is currently set to {currentDotnetInstallRoot.Path}. Do you want to change it to {resolvedInstallPath}?", + defaultValue: false); + } + } + else if (currentDotnetInstallRoot.InstallType == InstallType.Admin) + { + SpectreAnsiConsole.WriteLine($"You have an existing admin install of .NET in {currentDotnetInstallRoot.Path}. We can configure your system to use the new install of .NET " + + $"in {resolvedInstallPath} instead. This would mean that the admin install of .NET would no longer be accessible from the PATH or from Visual Studio."); + SpectreAnsiConsole.WriteLine("You can change this later with the \"dotnet defaultinstall\" command."); + resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( + $"Do you want to set the user install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", + defaultValue: true); + } + + // TODO: Add checks for whether PATH and DOTNET_ROOT need to be updated, or if the install is in an inconsistent state + } + else + { + resolvedSetDefaultInstall = false; // Default to not setting the default install path if not specified + } + } + + List additionalVersionsToInstall = new(); + + // Create a request and resolve it using the channel version resolver + var installRequest = new DotnetInstallRequest( + new DotnetInstallRoot(resolvedInstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(resolvedChannel), + InstallComponent.SDK, + new InstallRequestOptions + { + ManifestPath = _manifestPath + }); + + var resolvedVersion = _channelVersionResolver.Resolve(installRequest); + + if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot?.InstallType == InstallType.Admin) + { + if (_interactive) + { + var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); + if (latestAdminVersion is not null && resolvedVersion < new ReleaseVersion(latestAdminVersion)) + { + SpectreAnsiConsole.WriteLine($"Since the admin installs of the .NET SDK will no longer be accessible, we recommend installing the latest admin installed " + + $"version ({latestAdminVersion}) to the new user install location. This will make sure this version of the .NET SDK continues to be used for projects that don't specify a .NET SDK version in global.json."); + + if (SpectreAnsiConsole.Confirm($"Also install .NET SDK {latestAdminVersion}?", + defaultValue: true)) + { + additionalVersionsToInstall.Add(latestAdminVersion); + } + } + } + else + { + // TODO: Add command-line option for installing admin versions locally + } + } + + // TODO: Implement transaction / rollback? + + SpectreAnsiConsole.MarkupInterpolated($"Installing .NET SDK [blue]{resolvedVersion}[/] to [blue]{resolvedInstallPath}[/]..."); + + DotnetInstall? mainInstall; + + // Pass the _noProgress flag to the InstallerOrchestratorSingleton + // The orchestrator will handle installation with or without progress based on the flag + mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest, _noProgress); + if (mainInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); + return 1; + } + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.Version}, available via {mainInstall.InstallRoot}[/]"); + + // Install any additional versions + foreach (var additionalVersion in additionalVersionsToInstall) + { + // Create the request for the additional version + var additionalRequest = new DotnetInstallRequest( + new DotnetInstallRoot(resolvedInstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(additionalVersion), + InstallComponent.SDK, + new InstallRequestOptions + { + ManifestPath = _manifestPath + }); + + // Install the additional version with the same progress settings as the main installation + DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); + if (additionalInstall == null) + { + SpectreAnsiConsole.MarkupLine($"[red]Failed to install additional .NET SDK {additionalVersion}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.Version}, available via {additionalInstall.InstallRoot}[/]"); + } + } + + if (resolvedSetDefaultInstall == true) + { + _dotnetInstaller.ConfigureInstallType(InstallType.User, resolvedInstallPath); + } + + if (resolvedUpdateGlobalJson == true) + { + _dotnetInstaller.UpdateGlobalJson(globalJsonInfo!.GlobalJsonPath!, resolvedVersion!.ToString(), globalJsonInfo.AllowPrerelease, globalJsonInfo.RollForward); + } + + + SpectreAnsiConsole.WriteLine($"Complete!"); + + + return 0; + } + + + + string? ResolveChannelFromGlobalJson(string globalJsonPath) + { + //return null; + //return "9.0"; + return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); + } + +} diff --git a/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs new file mode 100644 index 000000000000..1b7c3e9c2a96 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; + +internal static class SdkInstallCommandParser +{ + + + public static readonly Argument ChannelArgument = new("channel") + { + HelpName = "CHANNEL", + Description = "The channel of the .NET SDK to install. For example: latest, 10, or 9.0.3xx. A specific version (for example 9.0.304) can also be specified.", + Arity = ArgumentArity.ZeroOrOne, + }; + + public static readonly Option InstallPathOption = new("--install-path") + { + HelpName = "INSTALL_PATH", + Description = "The path to install the .NET SDK to", + }; + + public static readonly Option SetDefaultInstallOption = new("--set-default-install") + { + Description = "Set the install path as the default dotnet install. This will update the PATH and DOTNET_ROOT environhment variables.", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in applicable global.json files to the installed SDK version", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = r => null + }; + + public static readonly Option ManifestPathOption = new("--manifest-path") + { + HelpName = "MANIFEST_PATH", + Description = "Custom path to the manifest file for tracking .NET SDK installations", + }; + + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; + public static readonly Option NoProgressOption = CommonOptions.NoProgressOption; + + private static readonly Command SdkInstallCommand = ConstructCommand(); + + public static Command GetSdkInstallCommand() + { + return SdkInstallCommand; + } + + // Trying to use the same command object for both "dnup install" and "dnup sdk install" causes the following exception: + // System.InvalidOperationException: Command install has more than one child named "channel". + // So we create a separate instance for each case + private static readonly Command RootInstallCommand = ConstructCommand(); + + public static Command GetRootInstallCommand() + { + return RootInstallCommand; + } + + private static Command ConstructCommand() + { + Command command = new("install", "Installs the .NET SDK"); + + command.Arguments.Add(ChannelArgument); + + command.Options.Add(InstallPathOption); + command.Options.Add(SetDefaultInstallOption); + command.Options.Add(UpdateGlobalJsonOption); + command.Options.Add(ManifestPathOption); + + command.Options.Add(InteractiveOption); + command.Options.Add(NoProgressOption); + + command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); + + return command; + } +} diff --git a/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs new file mode 100644 index 000000000000..efd9de75d60b --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/SdkCommandParser.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk +{ + internal class SdkCommandParser + { + private static readonly Command Command = ConstructCommand(); + + public static Command GetCommand() + { + return Command; + } + + private static Command ConstructCommand() + { + Command command = new("sdk", "Manage sdk installations"); + command.Subcommands.Add(SdkInstallCommandParser.GetSdkInstallCommand()); + command.Subcommands.Add(SdkUpdateCommandParser.GetSdkUpdateCommand()); + + //command.SetAction((parseResult) => parseResult.HandleMissingCommand()); + + return command; + } + } +} diff --git a/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs new file mode 100644 index 000000000000..855a5a8aaa45 --- /dev/null +++ b/src/Installer/dnup/Commands/Sdk/Update/SdkUpdateCommandParser.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; + +internal static class SdkUpdateCommandParser +{ + + public static readonly Option UpdateAllOption = new("--all") + { + Description = "Update all installed SDKs", + Arity = ArgumentArity.Zero + }; + + public static readonly Option UpdateGlobalJsonOption = new("--update-global-json") + { + Description = "Update the sdk version in applicable global.json files to the updated SDK version", + Arity = ArgumentArity.Zero + }; + + public static readonly Option InteractiveOption = CommonOptions.InteractiveOption; + public static readonly Option NoProgressOption = CommonOptions.NoProgressOption; + + private static readonly Command SdkUpdateCommand = ConstructCommand(); + + public static Command GetSdkUpdateCommand() + { + return SdkUpdateCommand; + } + + // Trying to use the same command object for both "dnup udpate" and "dnup sdk update" causes an InvalidOperationException + // So we create a separate instance for each case + private static readonly Command RootUpdateCommand = ConstructCommand(); + + public static Command GetRootUpdateCommand() + { + return RootUpdateCommand; + } + + private static Command ConstructCommand() + { + Command command = new("update", "Updates the .NET SDK"); + + command.Options.Add(UpdateAllOption); + command.Options.Add(UpdateGlobalJsonOption); + + command.Options.Add(InteractiveOption); + command.Options.Add(NoProgressOption); + + command.SetAction(parseResult => 0); + + return command; + } +} diff --git a/src/Installer/dnup/CommonOptions.cs b/src/Installer/dnup/CommonOptions.cs new file mode 100644 index 000000000000..943642c8a47a --- /dev/null +++ b/src/Installer/dnup/CommonOptions.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class CommonOptions +{ + public static Option InteractiveOption = new("--interactive") + { + Description = Strings.CommandInteractiveOptionDescription, + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => !IsCIEnvironmentOrRedirected() + }; + + public static Option NoProgressOption = new("--no-progress") + { + Description = "Disables progress display for operations", + Arity = ArgumentArity.ZeroOrOne + }; + + private static bool IsCIEnvironmentOrRedirected() => + new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; +} diff --git a/src/Installer/dnup/Constants.cs b/src/Installer/dnup/Constants.cs new file mode 100644 index 000000000000..cc612544b292 --- /dev/null +++ b/src/Installer/dnup/Constants.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + /// + /// Shared constants for the dnup application. + /// + internal static class Constants + { + /// + /// Mutex names used for synchronization. + /// + public static class MutexNames + { + /// + /// Mutex used during the final installation phase to protect the manifest file and extracting folder(s). + /// Mutex names MUST be valid file names on Unix. https://learn.microsoft.com/dotnet/api/system.threading.mutex.openexisting?view=net-9.0 + /// + public const string ModifyInstallationStates = "Global\\DnupFinalize"; + } + } +} diff --git a/src/Installer/dnup/DnupDebugHelper.cs b/src/Installer/dnup/DnupDebugHelper.cs new file mode 100644 index 000000000000..08810333fa3c --- /dev/null +++ b/src/Installer/dnup/DnupDebugHelper.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +// Copy of DebugHelper.cs in the SDK - port eventually. +internal static class DnupDebugHelper +{ + [Conditional("DEBUG")] + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = [.. args.Skip(1)]; + WaitForDebugger(); + } + } + + public static void WaitForDebugger() + { + int processId = Environment.ProcessId; + + Console.WriteLine("Waiting for debugger to attach. Press ENTER to continue"); + Console.WriteLine($"Process ID: {processId}"); + Console.ReadLine(); + } +} diff --git a/src/Installer/dnup/DnupManifestJsonContext.cs b/src/Installer/dnup/DnupManifestJsonContext.cs new file mode 100644 index 000000000000..0c7809151c1f --- /dev/null +++ b/src/Installer/dnup/DnupManifestJsonContext.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Microsoft.Dotnet.Installation.Internal; +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + [JsonSourceGenerationOptions(WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + Converters = new[] { typeof(ReleaseVersionJsonConverter) })] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(InstallComponent))] + [JsonSerializable(typeof(InstallArchitecture))] + [JsonSerializable(typeof(InstallType))] + [JsonSerializable(typeof(ManagementCadence))] + internal partial class DnupManifestJsonContext : JsonSerializerContext { } +} diff --git a/src/Installer/dnup/DnupSharedManifest.cs b/src/Installer/dnup/DnupSharedManifest.cs new file mode 100644 index 000000000000..b3b31d9ee354 --- /dev/null +++ b/src/Installer/dnup/DnupSharedManifest.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class DnupSharedManifest : IDnupManifest +{ + private string ManifestPath => GetManifestPath(); + + public DnupSharedManifest(string? manifestPath = null) + { + _customManifestPath = manifestPath; + EnsureManifestExists(); + } + + private void EnsureManifestExists() + { + if (!File.Exists(ManifestPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, JsonSerializer.Serialize(new List(), DnupManifestJsonContext.Default.ListDotnetInstall)); + } + } + + private string? _customManifestPath; + + private string GetManifestPath() + { + // Use explicitly provided path first (constructor argument) + if (!string.IsNullOrEmpty(_customManifestPath)) + { + return _customManifestPath; + } + + // Fall back to environment variable override + var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); + if (!string.IsNullOrEmpty(overridePath)) + { + return overridePath; + } + + // Default location + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "dnup", + "dnup_manifest.json"); + } + + private void AssertHasFinalizationMutex() + { + // Instead of attempting to reacquire the named mutex (which can create race conditions + // or accidentally succeed when we *don't* hold it), rely on the thread-local tracking + // implemented in ScopedMutex. This ensures we only assert based on a lock we actually obtained. + if (!ScopedMutex.CurrentThreadHoldsMutex) + { + throw new InvalidOperationException("The dnup manifest was accessed without holding the installation state mutex."); + } + } + + public IEnumerable GetInstalledVersions(IInstallationValidator? validator = null) + { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var json = File.ReadAllText(ManifestPath); + try + { + var installs = JsonSerializer.Deserialize(json, DnupManifestJsonContext.Default.ListDotnetInstall); + var validInstalls = installs ?? []; + + if (validator is not null) + { + var invalids = validInstalls.Where(i => !validator.Validate(i)).ToList(); + if (invalids.Count > 0) + { + validInstalls = validInstalls.Except(invalids).ToList(); + var newJson = JsonSerializer.Serialize(validInstalls, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, newJson); + } + } + return validInstalls; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"The dnup manifest is corrupt or inaccessible", ex); + } + } + + /// + /// Gets installed versions filtered by a specific muxer directory. + /// + /// Directory to filter by (must match the InstallRoot property) + /// Optional validator to check installation validity + /// Installations that match the specified directory + public IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null) + { + // TODO: Manifest read operations should protect against data structure changes and be able to reformat an old manifest version. + var installedVersions = GetInstalledVersions(validator); + var expectedInstallRootPath = Path.GetFullPath(installRoot.Path); + var installedVersionsInRoot = installedVersions + .Where(install => DnupUtilities.PathsEqual(Path.GetFullPath(install.InstallRoot.Path!), expectedInstallRootPath)); + return installedVersionsInRoot; + } + + public void AddInstalledVersion(DotnetInstall version) + { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.Add(version); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); + File.WriteAllText(ManifestPath, json); + } + + public void RemoveInstalledVersion(DotnetInstall version) + { + AssertHasFinalizationMutex(); + EnsureManifestExists(); + + var installs = GetInstalledVersions().ToList(); + installs.RemoveAll(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, version.InstallRoot.Path) && i.Version.Equals(version.Version)); + var json = JsonSerializer.Serialize(installs, DnupManifestJsonContext.Default.ListDotnetInstall); + File.WriteAllText(ManifestPath, json); + } +} diff --git a/src/Installer/dnup/DotnetInstallManager.cs b/src/Installer/dnup/DotnetInstallManager.cs new file mode 100644 index 000000000000..67e1652f37b4 --- /dev/null +++ b/src/Installer/dnup/DotnetInstallManager.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Cli.Utils; +using Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class DotnetInstallManager : IDotnetInstallManager +{ + private readonly IEnvironmentProvider _environmentProvider; + + public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) + { + _environmentProvider = environmentProvider ?? new EnvironmentProvider(); + } + + public DotnetInstallRootConfiguration? GetConfiguredInstallType() + { + + string? foundDotnet = _environmentProvider.GetCommandPath("dotnet"); + if (string.IsNullOrEmpty(foundDotnet)) + { + return null; + } + + string installDir = Path.GetDirectoryName(foundDotnet)!; + + + string? dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + bool isAdminInstall = installDir.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || + installDir.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + + var installRoot = new DotnetInstallRoot(installDir, InstallerUtilities.GetDefaultInstallArchitecture()); + + bool isSetAsDotnetRoot = DnupUtilities.PathsEqual(dotnetRoot, installDir); + + return new(installRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsOnPath: true, isSetAsDotnetRoot); + } + + public string GetDefaultDotnetInstallPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dotnet"); + } + + public GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory) + { + string? directory = initialDirectory; + while (!string.IsNullOrEmpty(directory)) + { + string globalJsonPath = Path.Combine(directory, "global.json"); + if (File.Exists(globalJsonPath)) + { + using var stream = File.OpenRead(globalJsonPath); + var contents = JsonSerializer.Deserialize( + stream, + GlobalJsonContentsJsonContext.Default.GlobalJsonContents); + return new GlobalJsonInfo + { + GlobalJsonPath = globalJsonPath, + GlobalJsonContents = contents + }; + } + var parent = Directory.GetParent(directory); + if (parent == null) + break; + directory = parent.FullName; + } + return new GlobalJsonInfo(); + } + + public string? GetLatestInstalledAdminVersion() + { + // TODO: Implement this + return null; + } + + public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) + { + foreach (var channelVersion in sdkVersions) + { + InstallSDK(dotnetRoot, progressContext, new UpdateChannel(channelVersion)); + } + } + + private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, UpdateChannel channnel) + { + DotnetInstallRequest request = new DotnetInstallRequest( + dotnetRoot, + channnel, + InstallComponent.SDK, + new InstallRequestOptions() + ); + + DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); + if (newInstall == null) + { + throw new Exception($"Failed to install .NET SDK {channnel.Name}"); + } + else + { + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.Version}, available via {newInstall.InstallRoot}[/]"); + } + } + + public void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null) => throw new NotImplementedException(); + + public void ConfigureInstallType(InstallType installType, string? dotnetRoot = null) + { + // Get current PATH + var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + string exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + // Remove only actual dotnet installation folders from PATH + pathEntries = pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName))).ToList(); + + switch (installType) + { + case InstallType.User: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Set DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + break; + case InstallType.Admin: + if (string.IsNullOrEmpty(dotnetRoot)) + throw new ArgumentNullException(nameof(dotnetRoot)); + // Add dotnetRoot to PATH + pathEntries.Insert(0, dotnetRoot); + // Unset DOTNET_ROOT + Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + break; + default: + throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); + } + // Update PATH + var newPath = string.Join(Path.PathSeparator, pathEntries); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User); + } +} diff --git a/src/Installer/dnup/GlobalJsonContents.cs b/src/Installer/dnup/GlobalJsonContents.cs new file mode 100644 index 000000000000..d55b7af24c23 --- /dev/null +++ b/src/Installer/dnup/GlobalJsonContents.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +public class GlobalJsonContents +{ + public SdkSection? Sdk { get; set; } + + public class SdkSection + { + public string? Version { get; set; } + public bool? AllowPrerelease { get; set; } + public string? RollForward { get; set; } + public string[]? Paths { get; set; } + } +} + +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(GlobalJsonContents))] +public partial class GlobalJsonContentsJsonContext : JsonSerializerContext +{ +} diff --git a/src/Installer/dnup/IDnupManifest.cs b/src/Installer/dnup/IDnupManifest.cs new file mode 100644 index 000000000000..b17c026fa4d0 --- /dev/null +++ b/src/Installer/dnup/IDnupManifest.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IDnupManifest + { + IEnumerable GetInstalledVersions(IInstallationValidator? validator = null); + IEnumerable GetInstalledVersions(DotnetInstallRoot installRoot, IInstallationValidator? validator = null); + void AddInstalledVersion(DotnetInstall version); + void RemoveInstalledVersion(DotnetInstall version); + } +} diff --git a/src/Installer/dnup/IDotnetInstallManager.cs b/src/Installer/dnup/IDotnetInstallManager.cs new file mode 100644 index 000000000000..4b8c742dbb7c --- /dev/null +++ b/src/Installer/dnup/IDotnetInstallManager.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Spectre.Console; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +// Install process +// - Resolve version to install from channel +// - Handle writing to install manifest and garbage collection +// - Orchestrate installation so that only one install happens at a time +// - Call into installer implementation + + +public interface IDotnetInstallManager +{ + GlobalJsonInfo GetGlobalJsonInfo(string initialDirectory); + + string GetDefaultDotnetInstallPath(); + + DotnetInstallRootConfiguration? GetConfiguredInstallType(); + + string? GetLatestInstalledAdminVersion(); + + void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions); + + void UpdateGlobalJson(string globalJsonPath, string? sdkVersion = null, bool? allowPrerelease = null, string? rollForward = null); + + void ConfigureInstallType(InstallType installType, string? dotnetRoot = null); + + +} + +public class GlobalJsonInfo +{ + public string? GlobalJsonPath { get; set; } + public GlobalJsonContents? GlobalJsonContents { get; set; } + + // Convenience properties for compatibility + public string? SdkVersion => GlobalJsonContents?.Sdk?.Version; + public bool? AllowPrerelease => GlobalJsonContents?.Sdk?.AllowPrerelease; + public string? RollForward => GlobalJsonContents?.Sdk?.RollForward; + public string? SdkPath => (GlobalJsonContents?.Sdk?.Paths is not null && GlobalJsonContents.Sdk.Paths.Length > 0) ? GlobalJsonContents.Sdk.Paths[0] : null; +} + +public record DotnetInstallRootConfiguration( + DotnetInstallRoot InstallRoot, + InstallType InstallType, + bool IsOnPath, + // We may also need additional information to handle the case of whether DOTNET_ROOT is not set or whether it's set to a different path + bool IsSetAsDotnetRoot) +{ + public string Path => InstallRoot.Path; +} diff --git a/src/Installer/dnup/IInstallationValidator.cs b/src/Installer/dnup/IInstallationValidator.cs new file mode 100644 index 000000000000..b49d87a1571c --- /dev/null +++ b/src/Installer/dnup/IInstallationValidator.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal interface IInstallationValidator + { + bool Validate(DotnetInstall install); + } +} diff --git a/src/Installer/dnup/InstallType.cs b/src/Installer/dnup/InstallType.cs new file mode 100644 index 000000000000..c63605e859f7 --- /dev/null +++ b/src/Installer/dnup/InstallType.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation; + +public enum InstallType +{ + User, + Admin, +} diff --git a/src/Installer/dnup/InstallerOrchestratorSingleton.cs b/src/Installer/dnup/InstallerOrchestratorSingleton.cs new file mode 100644 index 000000000000..a1610c21c0c5 --- /dev/null +++ b/src/Installer/dnup/InstallerOrchestratorSingleton.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class InstallerOrchestratorSingleton +{ + private static readonly InstallerOrchestratorSingleton _instance = new(); + + private InstallerOrchestratorSingleton() + { + } + + public static InstallerOrchestratorSingleton Instance => _instance; + + private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + + // Returns null on failure, DotnetInstall on success + public DotnetInstall? Install(DotnetInstallRequest installRequest, bool noProgress = false) + { + // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version + ReleaseVersion? versionToInstall = new ManifestChannelVersionResolver().Resolve(installRequest); + + if (versionToInstall == null) + { + Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); + return null; + } + + DotnetInstall install = new( + installRequest.InstallRoot, + versionToInstall, + installRequest.Component); + + string? customManifestPath = installRequest.Options.ManifestPath; + + // Check if the install already exists and we don't need to do anything + // read write mutex only for manifest? + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(install, customManifestPath)) + { + Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); + return install; + } + } + + using ArchiveDotnetExtractor installer = new(installRequest, versionToInstall, noProgress); + installer.Prepare(); + + // Extract and commit the install to the directory + using (var finalizeLock = modifyInstallStateMutex()) + { + if (InstallAlreadyExists(install, customManifestPath)) + { + return install; + } + + installer.Commit(); + + ArchiveInstallationValidator validator = new(); + if (validator.Validate(install)) + { + DnupSharedManifest manifestManager = new(customManifestPath); + manifestManager.AddInstalledVersion(install); + } + else + { + return null; + } + } + + return install; + } + + /// + /// Gets the existing installs from the manifest. Must hold a mutex over the directory. + /// + private IEnumerable GetExistingInstalls(DotnetInstallRoot installRoot, string? customManifestPath = null) + { + var manifestManager = new DnupSharedManifest(customManifestPath); + // Use the overload that filters by muxer directory + return manifestManager.GetInstalledVersions(installRoot); + } + + /// + /// Checks if the installation already exists. Must hold a mutex over the directory. + /// + private bool InstallAlreadyExists(DotnetInstall install, string? customManifestPath = null) + { + var existingInstalls = GetExistingInstalls(install.InstallRoot, customManifestPath); + + // Check if there's any existing installation that matches the version we're trying to install + return existingInstalls.Any(existing => + existing.Version.Equals(install.Version) && + existing.Component == install.Component); + } +} diff --git a/src/Installer/dnup/ManagementCadence.cs b/src/Installer/dnup/ManagementCadence.cs new file mode 100644 index 000000000000..963b5bae8a2b --- /dev/null +++ b/src/Installer/dnup/ManagementCadence.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + public enum ManagementCadenceType + { + DNUP, + GlobalJson, + Standalone + } + + public struct ManagementCadence + { + public ManagementCadence() + { + Type = ManagementCadenceType.DNUP; + Metadata = new Dictionary(); + } + public ManagementCadence(ManagementCadenceType managementStyle) : this() + { + Type = managementStyle; + Metadata = []; + } + + public ManagementCadenceType Type { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/src/Installer/dnup/ManifestChannelVersionResolver.cs b/src/Installer/dnup/ManifestChannelVersionResolver.cs new file mode 100644 index 000000000000..2a8b24aceb49 --- /dev/null +++ b/src/Installer/dnup/ManifestChannelVersionResolver.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class ManifestChannelVersionResolver +{ + public ReleaseVersion? Resolve(DotnetInstallRequest installRequest) + { + // If not fully specified, resolve to latest using ReleaseManifest + if (!installRequest.Channel.IsFullySpecifiedVersion()) + { + var manifest = new ReleaseManifest(); + return manifest.GetLatestVersionForChannel(installRequest.Channel, installRequest.Component); + } + + return new ReleaseVersion(installRequest.Channel.Name); + } +} diff --git a/src/Installer/dnup/Parser.cs b/src/Installer/dnup/Parser.cs new file mode 100644 index 000000000000..7032270df288 --- /dev/null +++ b/src/Installer/dnup/Parser.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Completions; +using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class Parser + { + public static ParserConfiguration ParserConfiguration { get; } = new() + { + EnablePosixBundling = false, + //ResponseFileTokenReplacer = TokenPerLine + }; + + public static InvocationConfiguration InvocationConfiguration { get; } = new() + { + //EnableDefaultExceptionHandler = false, + }; + + public static ParseResult Parse(string[] args) => RootCommand.Parse(args, ParserConfiguration); + public static int Invoke(ParseResult parseResult) => parseResult.Invoke(InvocationConfiguration); + + private static RootCommand RootCommand { get; } = ConfigureCommandLine(new() + { + Directives = { new DiagramDirective(), new SuggestDirective(), new EnvironmentVariablesDirective() } + }); + + private static RootCommand ConfigureCommandLine(RootCommand rootCommand) + { + rootCommand.Subcommands.Add(SdkCommandParser.GetCommand()); + rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand()); + rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand()); + + return rootCommand; + } + } +} diff --git a/src/Installer/dnup/Program.cs b/src/Installer/dnup/Program.cs new file mode 100644 index 000000000000..4969b276d7ef --- /dev/null +++ b/src/Installer/dnup/Program.cs @@ -0,0 +1,18 @@ + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Bootstrapper +{ + internal class DnupProgram + { + public static int Main(string[] args) + { + // Handle --debug flag using the standard .NET SDK pattern + // This is DEBUG-only and removes the --debug flag from args + DnupDebugHelper.HandleDebugSwitch(ref args); + + var parseResult = Parser.Parse(args); + return Parser.Invoke(parseResult); + } + } +} diff --git a/src/Installer/dnup/ReleaseVersionJsonConverter.cs b/src/Installer/dnup/ReleaseVersionJsonConverter.cs new file mode 100644 index 000000000000..3e20fa619541 --- /dev/null +++ b/src/Installer/dnup/ReleaseVersionJsonConverter.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Deployment.DotNet.Releases; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal sealed class ReleaseVersionJsonConverter : JsonConverter +{ + public override ReleaseVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + if (string.IsNullOrEmpty(value)) + { + throw new JsonException("ReleaseVersion value cannot be null or empty."); + } + + return new ReleaseVersion(value); + } + + public override void Write(Utf8JsonWriter writer, ReleaseVersion value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Installer/dnup/Strings.resx b/src/Installer/dnup/Strings.resx new file mode 100644 index 000000000000..b522f258c0d5 --- /dev/null +++ b/src/Installer/dnup/Strings.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Allows the command to stop and wait for user input or action. + + \ No newline at end of file diff --git a/src/Installer/dnup/dnup.csproj b/src/Installer/dnup/dnup.csproj new file mode 100644 index 000000000000..a272529118cb --- /dev/null +++ b/src/Installer/dnup/dnup.csproj @@ -0,0 +1,50 @@ + + + + Exe + net10.0 + enable + enable + true + + + $(NoWarn);CS8002 + + + + Microsoft.DotNet.Tools.Bootstrapper + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Installer/dnup/dnup.sln b/src/Installer/dnup/dnup.sln new file mode 100644 index 000000000000..f8be3f0124a7 --- /dev/null +++ b/src/Installer/dnup/dnup.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dnup", "dnup.csproj", "{FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE7DE2FC-400E-D3A1-8410-80BAA0C888BE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CE797DD7-D006-4A3F-94F3-1ED339F75821} + EndGlobalSection +EndGlobal diff --git a/src/Installer/dnup/xlf/Strings.cs.xlf b/src/Installer/dnup/xlf/Strings.cs.xlf new file mode 100644 index 000000000000..583703c00e49 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.cs.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.de.xlf b/src/Installer/dnup/xlf/Strings.de.xlf new file mode 100644 index 000000000000..02601d0c046b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.de.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.es.xlf b/src/Installer/dnup/xlf/Strings.es.xlf new file mode 100644 index 000000000000..4e14bffe08d7 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.es.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.fr.xlf b/src/Installer/dnup/xlf/Strings.fr.xlf new file mode 100644 index 000000000000..c34156a2faa2 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.fr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.it.xlf b/src/Installer/dnup/xlf/Strings.it.xlf new file mode 100644 index 000000000000..056f4ac60a30 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.it.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ja.xlf b/src/Installer/dnup/xlf/Strings.ja.xlf new file mode 100644 index 000000000000..d6a0e83e1bf1 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ja.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ko.xlf b/src/Installer/dnup/xlf/Strings.ko.xlf new file mode 100644 index 000000000000..02e4bfaa7562 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ko.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pl.xlf b/src/Installer/dnup/xlf/Strings.pl.xlf new file mode 100644 index 000000000000..b5f83b4e62e9 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pl.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.pt-BR.xlf b/src/Installer/dnup/xlf/Strings.pt-BR.xlf new file mode 100644 index 000000000000..e3f001a9a86b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.pt-BR.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.ru.xlf b/src/Installer/dnup/xlf/Strings.ru.xlf new file mode 100644 index 000000000000..2b09b5339f71 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.ru.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.tr.xlf b/src/Installer/dnup/xlf/Strings.tr.xlf new file mode 100644 index 000000000000..50a5749de51b --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.tr.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hans.xlf b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf new file mode 100644 index 000000000000..95c76c2608e3 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hans.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/dnup/xlf/Strings.zh-Hant.xlf b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf new file mode 100644 index 000000000000..6780ad69c7e8 --- /dev/null +++ b/src/Installer/dnup/xlf/Strings.zh-Hant.xlf @@ -0,0 +1,12 @@ + + + + + + Allows the command to stop and wait for user input or action. + Allows the command to stop and wait for user input or action. + + + + + \ No newline at end of file diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace new file mode 100644 index 000000000000..a38782dc9f20 --- /dev/null +++ b/src/Installer/installer.code-workspace @@ -0,0 +1,169 @@ +{ + "folders": [ + { + "path": ".", + "name": "installer" + }, + { + "path": "../../test/dnup.Tests", + "name": "dnup.Tests" + }, + { + "path": "../.." + } + ], + "settings": { + "dotnet.defaultSolution": "dnup/dnup.csproj", + "csharp.debug.console": "integratedTerminal", + "debug.terminal.clearBeforeReusing": true, + "editor.formatOnSave": true, + "omnisharp.useModernNet": true + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dnup (Default)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder:installer}/../../artifacts/bin/dnup/Debug/net10.0/dnup.dll", + "args": [ + "sdk", + "install" + ], + "cwd": "${workspaceFolder:installer}/dnup", + "console": "integratedTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + } + }, + { + "name": "Run dnup tests (launch)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/p:ContinuousIntegrationBuild=false", + ], + "cwd": "${workspaceFolder:dnup.Tests}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DNUP_TEST_DEBUG": "0" + } + }, + { + "name": "Debug dnup test", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", + "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "--no-build", + "--filter", + "FullyQualifiedName~${input:testName}", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "cwd": "${workspaceFolder:dnup.Tests}", + "console": "integratedTerminal", + "stopAtEntry": false, + "logging": { + "moduleLoad": false + }, + "env": { + "DNUP_TEST_DEBUG": "1" + } + } + ], + "compounds": [], + "inputs": [ + { + "id": "testName", + "type": "promptString", + "description": "Enter test name or partial test name to debug (e.g., 'InstallChannelVersionTest', 'Install', etc.)" + }, + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments for dnup (e.g., 'sdk install 9.0', 'runtime install lts', '--help')", + "default": "sdk install" + } + ] + }, + "tasks": { + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "dnup/dnup.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "options": { + "cwd": "${workspaceFolder:installer}" + }, + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "type": "process", + "command": "dotnet", + "args": [ + "test", + "${workspaceFolder:dnup.Tests}/dnup.Tests.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + "/p:ContinuousIntegrationBuild=false", + "/p:UseSharedCompilation=true", + "/p:Deterministic=false", + "/p:RunAnalyzers=false", + "/p:NoRestore=true" + ], + "options": { + "cwd": "${workspaceFolder:installer}" + }, + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false, + "clear": true + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } + }, + ] + } +} \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md index c5820fde91ae..75e0f77588e2 100644 --- a/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md +++ b/src/Microsoft.CodeAnalysis.NetAnalyzers/src/RulesMissingDocumentation.md @@ -2,7 +2,3 @@ Rule ID | Missing Help Link | Title | --------|-------------------|-------| -CA1873 | | Avoid potentially expensive logging | -CA1874 | | Use 'Regex.IsMatch' | -CA1875 | | Use 'Regex.Count' | -CA2023 | | Invalid braces in message template | diff --git a/test/dnup.Tests/DnupCollections.cs b/test/dnup.Tests/DnupCollections.cs new file mode 100644 index 000000000000..f81f54444d47 --- /dev/null +++ b/test/dnup.Tests/DnupCollections.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +/// +/// Collection definition that allows tests to run in parallel. +/// +[CollectionDefinition("DnupInstallCollection", DisableParallelization = false)] +public class DnupInstallCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} + +/// +/// Collection definition for reuse tests that allows tests to run in parallel. +/// +[CollectionDefinition("DnupReuseCollection", DisableParallelization = false)] +public class DnupReuseCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} + +/// +/// Collection definition for concurrency tests that allows tests to run in parallel. +/// +[CollectionDefinition("DnupConcurrencyCollection", DisableParallelization = false)] +public class DnupConcurrencyCollection +{ + // This class has no code, and is never created. Its purpose is to be the place to apply + // [CollectionDefinition] and all the collection settings. +} diff --git a/test/dnup.Tests/DnupE2Etest.cs b/test/dnup.Tests/DnupE2Etest.cs new file mode 100644 index 000000000000..01790ded49f4 --- /dev/null +++ b/test/dnup.Tests/DnupE2Etest.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; +using Xunit; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +/// +/// Tests for installing different .NET SDK versions using dnup. +/// Each test run can happen in parallel with other tests in different collections. +/// +[Collection("DnupInstallCollection")] +public class InstallEndToEndTests +{ + public static IEnumerable InstallChannels => new List + { + new object[] { "9" }, + new object[] { "9.0" }, + new object[] { "9.0.103" }, + new object[] { "9.0.1xx" }, + new object[] { "latest" }, + new object[] { "preview" }, + new object[] { "sts" }, + new object[] { "lts" }, + }; + + /// + /// End-to-end test for installing different .NET SDK versions using dnup. + /// This test creates a temporary directory and sets the current directory to it + /// to avoid conflicts with the global.json in the repository root. + /// + [Theory] + [MemberData(nameof(InstallChannels))] + public void Test(string channel) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + // First verify what version dnup should resolve this channel to + var updateChannel = new UpdateChannel(channel); + var expectedVersion = new ManifestChannelVersionResolver().Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + updateChannel, + InstallComponent.SDK, + new InstallRequestOptions())); + + expectedVersion.Should().NotBeNull($"Channel {channel} should resolve to a valid version"); + + Console.WriteLine($"Channel '{channel}' resolved to version: {expectedVersion}"); + + // Execute the command with explicit manifest path as a separate process + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + + DnupProcessResult result = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + result.ExitCode.Should().Be(0, + $"dnup exited with code {result.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(result)}"); + + Directory.Exists(testEnv.InstallPath).Should().BeTrue(); + Directory.Exists(Path.GetDirectoryName(testEnv.ManifestPath)).Should().BeTrue(); + + // Verify the installation was properly recorded in the manifest + List installs = []; + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + installs = manifest.GetInstalledVersions().ToList(); + } + + installs.Should().NotBeEmpty(); + + var matchingInstalls = installs.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); + matchingInstalls.Should().ContainSingle(); + + var install = matchingInstalls[0]; + install.Component.Should().Be(InstallComponent.SDK); + + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for installed version {install.Version} at {testEnv.InstallPath}"); + + // Verify the installed version matches what the resolver predicted + if (!updateChannel.IsFullySpecifiedVersion()) + { + // For channels that are not fully specified versions (like "9", "preview", "lts"), + // verify that the installed version matches what the resolver predicted + install.Version.ToString().Should().Be(expectedVersion!.ToString(), + $"Installed version should match resolved version for channel {channel}"); + } + else + { + // For fully specified versions (like "9.0.103"), the installed version should be exactly what was requested + install.Version.ToString().Should().Be(channel); + } + } +} + +/// +/// Tests that verify reuse behavior of dnup installations. +/// Each test run can happen in parallel with other tests in different collections. +/// +[Collection("DnupReuseCollection")] +public class ReuseEndToEndTests +{ + /// + /// Test that verifies that installing the same SDK version twice doesn't require + /// dnup to download and install it again. + /// + [Fact] + public void TestReusesExistingInstall() + { + // We'll use a specific version for this test to ensure consistent results + const string channel = "9.0.103"; + + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + var args = DnupTestUtilities.BuildArguments(channel, testEnv.InstallPath, testEnv.ManifestPath); + + // Execute dnup to install the SDK the first time with explicit manifest path as a separate process + Console.WriteLine($"First installation of {channel}"); + DnupProcessResult firstInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + firstInstall.ExitCode.Should().Be(0, + $"First installation failed with exit code {firstInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(firstInstall)}"); + + List firstDnupInstalls = []; + // Verify the installation was successful + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + firstDnupInstalls = manifest.GetInstalledVersions().ToList(); + } + + var firstInstallRecord = firstDnupInstalls.Single(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)); + DnupTestUtilities.ValidateInstall(firstInstallRecord).Should().BeTrue( + $"ArchiveInstallationValidator failed for initial install of {channel} at {testEnv.InstallPath}"); + + // Now install the same SDK again and capture the console output + Console.WriteLine($"Installing .NET SDK {channel} again (should be skipped)"); + DnupProcessResult secondInstall = DnupTestUtilities.RunDnupProcess(args, captureOutput: true, workingDirectory: testEnv.TempRoot); + secondInstall.ExitCode.Should().Be(0, + $"Second installation failed with exit code {secondInstall.ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(secondInstall)}"); + + DnupTestUtilities.AssertOutput(secondInstall, output => + { + output.Should().Contain("is already installed, skipping installation", + "dnup should detect that the SDK is already installed and skip the installation"); + + output.Should().NotContain("Downloading .NET SDK", + "dnup should not attempt to download the SDK again"); + }); + + List matchingInstalls = new(); + // Verify the installation record in the manifest hasn't changed + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + var installs = manifest.GetInstalledVersions(); + matchingInstalls = installs.Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)).ToList(); + } + + // Should still only have one installation + matchingInstalls.Should().ContainSingle(); + // And it should be for the specified version + matchingInstalls[0].Version.ToString().Should().Be(channel); + DnupTestUtilities.ValidateInstall(matchingInstalls[0]).Should().BeTrue( + $"ArchiveInstallationValidator failed after reinstall attempt for {channel} at {testEnv.InstallPath}"); + } +} + +/// +/// Tests that cover concurrent installs targeting the same install root and manifest. +/// +[Collection("DnupConcurrencyCollection")] +public class ConcurrentInstallationTests +{ + public static IEnumerable ConcurrentInstallChannels => new List + { + new object[] { "9.0.103", "9.0.103", false }, + new object[] { "9.0.103", "preview", true } + }; + + [Theory] + [MemberData(nameof(ConcurrentInstallChannels))] + public async Task ConcurrentInstallsSerializeViaGlobalMutex(string firstChannel, string secondChannel, bool expectDistinct) + { + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + var resolver = new ManifestChannelVersionResolver(); + ReleaseVersion? firstResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(firstChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + + ReleaseVersion? secondResolved = resolver.Resolve( + new DotnetInstallRequest( + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + new UpdateChannel(secondChannel), + InstallComponent.SDK, + new InstallRequestOptions())); + + firstResolved.Should().NotBeNull($"Channel {firstChannel} should resolve to a version"); + secondResolved.Should().NotBeNull($"Channel {secondChannel} should resolve to a version"); + + if (expectDistinct && string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Skipping concurrent distinct-version scenario because both channels resolved to {firstResolved}"); + return; + } + var args1 = DnupTestUtilities.BuildArguments(firstChannel, testEnv.InstallPath, testEnv.ManifestPath); + var args2 = DnupTestUtilities.BuildArguments(secondChannel, testEnv.InstallPath, testEnv.ManifestPath); + + var installTask1 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args1, captureOutput: true, workingDirectory: testEnv.TempRoot)); + var installTask2 = Task.Run(() => DnupTestUtilities.RunDnupProcess(args2, captureOutput: true, workingDirectory: testEnv.TempRoot)); + + DnupProcessResult[] results = await Task.WhenAll(installTask1, installTask2); + + results[0].ExitCode.Should().Be(0, + $"First concurrent install failed with exit code {results[0].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[0])}"); + results[1].ExitCode.Should().Be(0, + $"Second concurrent install failed with exit code {results[1].ExitCode}. Output:\n{DnupTestUtilities.FormatOutputForAssertions(results[1])}"); + + var installs = new List(); + + using (var finalizeLock = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + var manifest = new DnupSharedManifest(testEnv.ManifestPath); + installs = manifest.GetInstalledVersions() + .Where(i => DnupUtilities.PathsEqual(i.InstallRoot.Path, testEnv.InstallPath)) + .ToList(); + } + + int expectedInstallCount = string.Equals(firstResolved!.ToString(), secondResolved!.ToString(), StringComparison.OrdinalIgnoreCase) ? 1 : 2; + installs.Should().HaveCount(expectedInstallCount); + + var expectedVersions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + firstResolved.ToString()!, + secondResolved!.ToString()! + }; + + foreach (var install in installs) + { + install.Component.Should().Be(InstallComponent.SDK); + expectedVersions.Should().Contain(install.Version.ToString()); + DnupTestUtilities.ValidateInstall(install).Should().BeTrue( + $"ArchiveInstallationValidator failed for concurrent install {install.Version} at {testEnv.InstallPath}"); + } + + var actualVersions = installs.Select(i => i.Version.ToString()).ToHashSet(StringComparer.OrdinalIgnoreCase); + actualVersions.Should().BeEquivalentTo(expectedVersions); + } +} diff --git a/test/dnup.Tests/LibraryTests.cs b/test/dnup.Tests/LibraryTests.cs new file mode 100644 index 000000000000..0b3c68fc4b0f --- /dev/null +++ b/test/dnup.Tests/LibraryTests.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class LibraryTests +{ + ITestOutputHelper Log { get; } + + public LibraryTests(ITestOutputHelper log) + { + Log = log; + } + + [Theory] + [InlineData("9")] + [InlineData("latest")] + public void LatestVersionForChannelCanBeInstalled(string channel) + { + var releaseInfoProvider = InstallerFactory.CreateReleaseInfoProvider(); + + var latestVersion = releaseInfoProvider.GetLatestVersion(InstallComponent.SDK, channel); + Log.WriteLine($"Latest version for channel '{channel}' is '{latestVersion}'"); + + var installer = InstallerFactory.CreateInstaller(); + + using var testEnv = DnupTestUtilities.CreateTestEnvironment(); + + Log.WriteLine($"Installing to path: {testEnv.InstallPath}"); + + installer.Install( + new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()), + InstallComponent.SDK, + latestVersion!); + } +} diff --git a/test/dnup.Tests/ParserTests.cs b/test/dnup.Tests/ParserTests.cs new file mode 100644 index 000000000000..20f4958472b0 --- /dev/null +++ b/test/dnup.Tests/ParserTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests; + +public class ParserTests +{ + [Fact] + public void Parser_ShouldParseValidCommands() + { + // Arrange + var args = new[] { "sdk", "install", "8.0" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleInvalidCommands() + { + // Arrange + var args = new[] { "invalid-command" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().NotBeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleSdkHelp() + { + // Arrange + var args = new[] { "sdk", "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_ShouldHandleRootHelp() + { + // Arrange + var args = new[] { "--help" }; + + // Act + var parseResult = Parser.Parse(args); + + // Assert + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } +} diff --git a/test/dnup.Tests/Properties/AssemblyInfo.cs b/test/dnup.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..7173633619d7 --- /dev/null +++ b/test/dnup.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using Xunit; + +// Enable parallel test execution +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass, DisableTestParallelization = false, MaxParallelThreads = 0)] diff --git a/test/dnup.Tests/ReleaseManifestTests.cs b/test/dnup.Tests/ReleaseManifestTests.cs new file mode 100644 index 000000000000..63dab25f79a3 --- /dev/null +++ b/test/dnup.Tests/ReleaseManifestTests.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Xunit; + +namespace Microsoft.DotNet.Tools.Dnup.Tests +{ + public class ReleaseManifestTests + { + [Fact] + public void GetLatestVersionForChannel_MajorOnly_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9"), InstallComponent.SDK); + Assert.NotNull(version); + } + + [Fact] + public void GetLatestVersionForChannel_MajorMinor_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9"), InstallComponent.SDK); + Assert.NotNull(version); + Assert.StartsWith("9.0.", version.ToString()); + } + + [Fact] + public void GetLatestVersionForChannel_FeatureBand_ReturnsLatestVersion() + { + var manifest = new ReleaseManifest(); + + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("9.0.1xx"), InstallComponent.SDK); + Console.WriteLine($"Version found: {version}"); + + // Feature band version should be returned in the format 9.0.100 + Assert.NotNull(version); + Assert.Matches(@"^9\.0\.1\d{2}$", version.ToString()); + } + + [Fact] + public void GetLatestVersionForChannel_LTS_ReturnsLatestLTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("lts"), InstallComponent.SDK); + + Console.WriteLine($"LTS Version found: {version}"); + + // Check that we got a version + Assert.NotNull(version); + + // LTS versions should have even major versions (e.g., 6.0, 8.0, 10.0) + Assert.True(version.Minor % 2 == 0, $"LTS version {version} should have an even minor version"); + + // Should not be a preview version + Assert.Null(version.Prerelease); + } + + [Fact] + public void GetLatestVersionForChannel_STS_ReturnsLatestSTSVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("sts"), InstallComponent.SDK); + + Console.WriteLine($"STS Version found: {version}"); + + // Check that we got a version + Assert.NotNull(version); + + // STS versions should have odd major versions (e.g., 7.0, 9.0, 11.0) + Assert.True(version.Major % 2 != 0, $"STS version {version} should have an odd minor version"); + + // Should not be a preview version + Assert.Null(version.Prerelease); + } + + [Fact] + public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() + { + var manifest = new ReleaseManifest(); + var version = manifest.GetLatestVersionForChannel(new UpdateChannel("preview"), InstallComponent.SDK); + + Console.WriteLine($"Preview Version found: {version}"); + + // Check that we got a version + Assert.NotNull(version); + + // Preview versions should contain a hyphen (e.g., "11.0.0-preview.1") + Assert.NotNull(version.Prerelease); + + // Should contain preview, rc, beta, or alpha + Assert.True( + version.Prerelease.Contains("preview", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("rc", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("beta", StringComparison.OrdinalIgnoreCase) || + version.Prerelease.Contains("alpha", StringComparison.OrdinalIgnoreCase), + $"Version {version} should be a preview/rc/beta/alpha version" + ); + } + } +} diff --git a/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs b/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs new file mode 100644 index 000000000000..854c7039495d --- /dev/null +++ b/test/dnup.Tests/Utilities/ConsoleOutputCapture.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Helper class to capture console output for testing +/// +internal class ConsoleOutputCapture : IDisposable +{ + private readonly TextWriter _originalConsoleOut; + private readonly StringBuilder _stringBuilder; + private readonly StringWriter _stringWriter; + + public ConsoleOutputCapture() + { + _originalConsoleOut = Console.Out; + _stringBuilder = new StringBuilder(); + _stringWriter = new StringWriter(_stringBuilder); + Console.SetOut(_stringWriter); + } + + public string GetOutput() + { + _stringWriter.Flush(); + return _stringBuilder.ToString(); + } + + public void Dispose() + { + Console.SetOut(_originalConsoleOut); + _stringWriter.Dispose(); + } +} diff --git a/test/dnup.Tests/Utilities/DnupTestUtilities.cs b/test/dnup.Tests/Utilities/DnupTestUtilities.cs new file mode 100644 index 000000000000..a0d7f9d7b042 --- /dev/null +++ b/test/dnup.Tests/Utilities/DnupTestUtilities.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Common utilities for dnup tests +/// +internal static class DnupTestUtilities +{ + /// + /// Creates a test environment with proper temporary directories + /// + public static TestEnvironment CreateTestEnvironment() + { + string tempRoot = Path.Combine(Path.GetTempPath(), "dnup-e2e", Guid.NewGuid().ToString("N")); + string installPath = Path.Combine(tempRoot, "dotnet-root"); + string manifestPath = Path.Combine(tempRoot, "dnup_manifest.json"); + + // Create necessary directories + Directory.CreateDirectory(tempRoot); + Directory.CreateDirectory(installPath); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + + return new TestEnvironment(tempRoot, installPath, manifestPath); + } + + /// + /// Builds command line arguments for dnup + /// + public static string[] BuildArguments(string channel, string installPath, string? manifestPath = null, bool disableProgress = true) + { + var args = new List + { + "sdk", + "install", + channel, + "--install-path", + installPath + }; + + if (!ShouldForceDebug()) + { + args.Add("--interactive"); + args.Add("false"); + } + + // Add manifest path option if specified for test isolation + if (!string.IsNullOrEmpty(manifestPath)) + { + args.Add("--manifest-path"); + args.Add(manifestPath); + } + + // Add no-progress option when running tests in parallel to avoid Spectre.Console exclusivity issues + if (disableProgress) + { + args.Add("--no-progress"); + } + + return [.. args]; + } + + /// + /// Runs the dnup executable as a separate process. + /// + /// Command line arguments for dnup. + /// Whether to capture and return the output. + /// Process result including exit code and output (if captured). + public static DnupProcessResult RunDnupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) + { + if (ShouldForceDebug()) + { + args = EnsureDebugFlag(args); + } + + string repoRoot = GetRepositoryRoot(); + string dnupPath = LocateDnupAssembly(repoRoot); + + using var process = new Process(); + string repoDotnet = Path.Combine(repoRoot, ".dotnet", DnupUtilities.GetDotnetExeName()); + process.StartInfo.FileName = File.Exists(repoDotnet) ? repoDotnet : DnupUtilities.GetDotnetExeName(); + process.StartInfo.Arguments = $"\"{dnupPath}\" {string.Join(" ", args.Select(a => $"\"{a}\""))}"; + + bool useShellExecute = ShouldForceDebug(); + process.StartInfo.UseShellExecute = useShellExecute; + process.StartInfo.CreateNoWindow = !useShellExecute; + process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; + + bool shouldCaptureOutput = captureOutput && !useShellExecute; + + StringBuilder outputBuilder = new(); + if (shouldCaptureOutput) + { + process.StartInfo.RedirectStandardOutput = shouldCaptureOutput; + process.StartInfo.RedirectStandardError = shouldCaptureOutput; + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + outputBuilder.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + outputBuilder.AppendLine(e.Data); + } + }; + } + + process.Start(); + + if (shouldCaptureOutput) + { + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + if (ShouldForceDebug()) + { + Console.WriteLine($"Started dnup process with PID: {process.Id}"); + Console.WriteLine(useShellExecute + ? "Interactive console window launched for debugger attachment." + : "Process is sharing the current console for debugger attachment."); + Console.WriteLine("To attach debugger: Debug -> Attach to Process -> Select the dotnet.exe process"); + } + + process.WaitForExit(); + + string output = shouldCaptureOutput ? outputBuilder.ToString() : string.Empty; + return new DnupProcessResult(process.ExitCode, output, shouldCaptureOutput); + } + + private static string[] EnsureDebugFlag(string[] args) + { + if (args.Any(a => string.Equals(a, "--debug", StringComparison.OrdinalIgnoreCase))) + { + return args; + } + + string[] updated = new string[args.Length + 1]; + updated[0] = "--debug"; + Array.Copy(args, 0, updated, 1, args.Length); + return updated; + } + + private static bool ShouldForceDebug() + { + string? value = Environment.GetEnvironmentVariable("DNUP_TEST_DEBUG"); + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Executes output assertions only when dnup output was captured. + /// + public static void AssertOutput(DnupProcessResult result, Action assertion) + { + if (!result.OutputCaptured) + { + Console.WriteLine("Skipping output assertions because dnup output was not captured (debug mode with ShellExecute)."); + return; + } + + assertion(result.Output); + } + + /// + /// Formats dnup output for inclusion in assertion messages. + /// + public static string FormatOutputForAssertions(DnupProcessResult result) => + result.OutputCaptured ? result.Output : "[dnup output not captured; run without --debug to capture output]"; + + private static string GetRepositoryRoot() + { + var currentDirectory = new DirectoryInfo(AppContext.BaseDirectory); + while (currentDirectory is not null) + { + if (File.Exists(Path.Combine(currentDirectory.FullName, "sdk.slnx"))) + { + return currentDirectory.FullName; + } + + currentDirectory = currentDirectory.Parent; + } + + throw new InvalidOperationException($"Unable to locate repository root from base directory '{AppContext.BaseDirectory}'."); + } + + public static bool ValidateInstall(DotnetInstall install) + { + var validator = new ArchiveInstallationValidator(); + return validator.Validate(install); + } + + private static string LocateDnupAssembly(string repoRoot) + { + string artifactsRoot = Path.Combine(repoRoot, "artifacts", "bin", "dnup"); + if (!Directory.Exists(artifactsRoot)) + { + throw new FileNotFoundException($"dnup build output not found. Expected directory: {artifactsRoot}"); + } + + var testAssemblyDirectory = new DirectoryInfo(AppContext.BaseDirectory); + string? tfm = testAssemblyDirectory.Name; + string? configuration = testAssemblyDirectory.Parent?.Name; + + if (!string.IsNullOrEmpty(tfm)) + { + IEnumerable configurationCandidates = BuildConfigurationCandidates(configuration); + foreach (string candidateConfig in configurationCandidates) + { + string candidate = Path.Combine(artifactsRoot, candidateConfig, tfm, "dnup.dll"); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + string? fallback = Directory.EnumerateFiles(artifactsRoot, "dnup.dll", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + + if (fallback is not null) + { + return fallback; + } + + throw new FileNotFoundException($"dnup executable not found under {artifactsRoot}. Ensure the dnup project is built before running tests."); + } + + private static IEnumerable BuildConfigurationCandidates(string? configuration) + { + var candidates = new List(); + if (!string.IsNullOrEmpty(configuration)) + { + candidates.Add(configuration); + } + + if (!candidates.Contains("Release", StringComparer.OrdinalIgnoreCase)) + { + candidates.Insert(0, "Release"); + } + + if (!candidates.Contains("Debug", StringComparer.OrdinalIgnoreCase)) + { + candidates.Add("Debug"); + } + + return candidates.Distinct(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Maps System.Runtime.InteropServices.Architecture to Microsoft.Dotnet.Installation.InstallArchitecture + /// + public static InstallArchitecture MapArchitecture(Architecture architecture) => + InstallerUtilities.GetInstallArchitecture(architecture); +} + +internal readonly record struct DnupProcessResult(int ExitCode, string Output, bool OutputCaptured) +{ + public void Deconstruct(out int exitCode, out string output) + { + exitCode = ExitCode; + output = Output; + } + + public void Deconstruct(out int exitCode, out string output, out bool outputCaptured) + { + exitCode = ExitCode; + output = Output; + outputCaptured = OutputCaptured; + } +} diff --git a/test/dnup.Tests/Utilities/TestEnvironment.cs b/test/dnup.Tests/Utilities/TestEnvironment.cs new file mode 100644 index 000000000000..ef3af45e496e --- /dev/null +++ b/test/dnup.Tests/Utilities/TestEnvironment.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Represents a temporary test environment with isolated directories and environment variables +/// +internal class TestEnvironment : IDisposable +{ + private readonly string _originalCurrentDirectory; + + public string TempRoot { get; } + public string InstallPath { get; } + public string ManifestPath { get; } + + public TestEnvironment(string tempRoot, string installPath, string manifestPath) + { + TempRoot = tempRoot; + InstallPath = installPath; + ManifestPath = manifestPath; + + try + { + _originalCurrentDirectory = Environment.CurrentDirectory; + } + catch (Exception ex) + { + // If we can't get the current directory (which can happen in CI), + // use the temp directory as a fallback + _originalCurrentDirectory = tempRoot; + Console.WriteLine($"Warning: Could not get current directory: {ex.Message}. Using temp directory as fallback."); + } + + // Set default install path as environment variable + // This is required for cases where the install path is needed but not explicitly provided + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", installPath); + + // Change current directory to the temp directory to avoid global.json in repository root + Environment.CurrentDirectory = tempRoot; + } + + public void Dispose() + { + try + { + // Restore original environment + Environment.CurrentDirectory = _originalCurrentDirectory; + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not restore current directory: {ex.Message}"); + } + + // Clear the environment variable we set + Environment.SetEnvironmentVariable("DOTNET_TESTHOOK_DEFAULT_INSTALL_PATH", null); + + // Clean up + if (Directory.Exists(TempRoot)) + { + try + { + Directory.Delete(TempRoot, recursive: true); + } + catch (IOException) + { + // Files might be locked, but we tried our best to clean up + Console.WriteLine($"Warning: Could not clean up temp directory: {TempRoot}"); + } + } + } +} diff --git a/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs new file mode 100644 index 000000000000..7c03d84164ec --- /dev/null +++ b/test/dnup.Tests/Utilities/UpdateChannelExtensions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Deployment.DotNet.Releases; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Dnup.Tests.Utilities; + +/// +/// Extension methods for working with UpdateChannel in tests +/// +internal static class UpdateChannelExtensions +{ + /// + /// Determines if a channel represents a fully specified version (e.g. 9.0.103) + /// as opposed to a feature band (e.g. 9.0.1xx) or a special channel (e.g. lts) + /// + public static bool IsFullySpecifiedVersion(this UpdateChannel channel) + { + var parts = channel.Name.Split('.'); + + // Special channels are not fully specified versions + if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // For a version to be fully specified, it needs at least 3 parts (major.minor.patch) + if (parts.Length < 3) + { + return false; + } + + // If the third part contains 'xx' (like '1xx'), it's a feature band, not a fully specified version + if (parts[2].Contains("xx")) + { + return false; + } + + // If we can parse the third part as an integer, it's likely a fully specified version + return int.TryParse(parts[2], out _); + } +} diff --git a/test/dnup.Tests/dnup.Tests.csproj b/test/dnup.Tests/dnup.Tests.csproj new file mode 100644 index 000000000000..d47cc410c996 --- /dev/null +++ b/test/dnup.Tests/dnup.Tests.csproj @@ -0,0 +1,16 @@ + + + + enable + $(ToolsetTargetFramework) + Exe + true + Tests\$(MSBuildProjectName) + + + + + + + + diff --git a/test/dnup.Tests/xunit.runner.json b/test/dnup.Tests/xunit.runner.json new file mode 100644 index 000000000000..6ebe966d8770 --- /dev/null +++ b/test/dnup.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "longRunningTestSeconds": 300 +}