From f8baf2a475b0a55d5649a970d695d44e59783cf1 Mon Sep 17 00:00:00 2001 From: Ivan Zub Date: Mon, 1 Jul 2019 12:54:19 +0200 Subject: [PATCH] Fallback to mono when running on Linux or MacOS for Nuget.exe (#829) * When running not on windows try to fallback to mono for Nuget.exe That makes nuget updates practically crossplatform. * Review comments Better error messages Better method naming * Add a missing dot * Pass correct arguments to the external process * Unit tests for Mono Executor * Adjust integration tests to run cross platform On mac restore command was required to run this integration test. Otherwise `Unable to locate the packages folder. Verify all the packages are restored before running 'nuget.exe update'.` error was thrown. * Update travis config to use mono * Fix code style and typos * Try to run azure pipeline on linux as well * Run code coverage only on windows * Add note about experimental support for mono * Log error when NuGet.exe can't be executed * Fix casing of macOS in docs * Remove Task.FromResult in favour of NSubstitute native overload --- .azure-build.yml | 16 +++- .travis.yml | 4 +- .../Process/NuGetUpdatePackageCommandTests.cs | 39 ++++++--- .../Process/MonoExecutorTests.cs | 83 +++++++++++++++++++ NuKeeper.Update/Process/IMonoExecutor.cs | 10 +++ NuKeeper.Update/Process/MonoExecutor.cs | 48 +++++++++++ .../Process/NuGetFileRestoreCommand.cs | 39 ++++++--- .../Process/NuGetUpdatePackageCommand.cs | 28 +++++-- NuKeeper/ContainerUpdateRegistration.cs | 1 + README.md | 2 + site/content/basics/installation.md | 2 + 11 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 NuKeeper.Update.Tests/Process/MonoExecutorTests.cs create mode 100644 NuKeeper.Update/Process/IMonoExecutor.cs create mode 100644 NuKeeper.Update/Process/MonoExecutor.cs diff --git a/.azure-build.yml b/.azure-build.yml index b7330a6f3..ec513bf83 100644 --- a/.azure-build.yml +++ b/.azure-build.yml @@ -1,5 +1,13 @@ -queue: - name: Hosted VS2017 +strategy: + matrix: + Linux: + imageName: 'ubuntu-16.04' + Windows: + imageName: 'vs2017-win2016' + +pool: + vmImage: $(imageName) + variables: buildConfiguration: Release DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true @@ -33,6 +41,7 @@ steps: dotnet tool install -g dotnet-reportgenerator-globaltool reportgenerator -reports:$(Build.SourcesDirectory)/**/coverage.cobertura.xml -targetdir:$(Build.SourcesDirectory)/CodeCoverage -reporttypes:HtmlInline_AzurePipelines;Cobertura displayName: Create Code coverage report + condition: eq(variables['Agent.OS'], 'Windows_NT') - task: PublishCodeCoverageResults@1 displayName: 'Publish code coverage' @@ -40,9 +49,10 @@ steps: codeCoverageTool: Cobertura summaryFileLocation: '$(Build.SourcesDirectory)/CodeCoverage/Cobertura.xml' reportDirectory: '$(Build.SourcesDirectory)/CodeCoverage' + condition: eq(variables['Agent.OS'], 'Windows_NT') - task: PublishBuildArtifacts@1 displayName: 'Publish Artifact: drop for master' inputs: PathtoPublish: '$(Build.SourcesDirectory)' - condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master')) + condition: and(and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master')), eq(variables['Agent.OS'], 'Windows_NT')) diff --git a/.travis.yml b/.travis.yml index 88387f08b..c6d642cde 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: csharp -mono: none +mono: 5.18.1 dist: xenial dotnet: 2.2.202 script: - dotnet build -c Release NuKeeper.sln /m:1 - - dotnet test -c Release NuKeeper.sln --filter "TestCategory!=WindowsOnly" + - dotnet test -c Release NuKeeper.sln diff --git a/NuKeeper.Integration.Tests/NuGet/Process/NuGetUpdatePackageCommandTests.cs b/NuKeeper.Integration.Tests/NuGet/Process/NuGetUpdatePackageCommandTests.cs index e3da5cd52..9bf6d79e0 100644 --- a/NuKeeper.Integration.Tests/NuGet/Process/NuGetUpdatePackageCommandTests.cs +++ b/NuKeeper.Integration.Tests/NuGet/Process/NuGetUpdatePackageCommandTests.cs @@ -16,11 +16,10 @@ namespace NuKeeper.Integration.Tests.NuGet.Process { [TestFixture] - [Category("WindowsOnly")] // Windows only due to NuGetUpdatePackageCommand public class NuGetUpdatePackageCommandTests { private readonly string _testDotNetClassicProject = -@" + @" v4.7 @@ -29,10 +28,10 @@ public class NuGetUpdatePackageCommandTests "; private readonly string _testPackagesConfig = -@""; + @""; private readonly string _nugetConfig = -@""; + @""; private IFolder _uniqueTemporaryFolder = null; @@ -64,28 +63,44 @@ public async Task ShouldUpdateDotnetClassicProject() var packagesFolder = Path.Combine(workDirectory, "packages"); Directory.CreateDirectory(packagesFolder); - var projectContents = _testDotNetClassicProject.Replace("{packageVersion}", oldPackageVersion, StringComparison.OrdinalIgnoreCase); + var projectContents = _testDotNetClassicProject.Replace("{packageVersion}", oldPackageVersion, + StringComparison.OrdinalIgnoreCase); var projectPath = Path.Combine(workDirectory, testProject); await File.WriteAllTextAsync(projectPath, projectContents); - var packagesConfigContents = _testPackagesConfig.Replace("{packageVersion}", oldPackageVersion, StringComparison.OrdinalIgnoreCase); + var packagesConfigContents = _testPackagesConfig.Replace("{packageVersion}", oldPackageVersion, + StringComparison.OrdinalIgnoreCase); var packagesConfigPath = Path.Combine(workDirectory, "packages.config"); await File.WriteAllTextAsync(packagesConfigPath, packagesConfigContents); await File.WriteAllTextAsync(Path.Combine(workDirectory, "nuget.config"), _nugetConfig); var logger = Substitute.For(); - var command = new NuGetUpdatePackageCommand(logger, new NuGetPath(logger), new ExternalProcess(logger)); + var externalProcess = new ExternalProcess(logger); + + var monoExecutor = new MonoExecutor(logger, externalProcess); + + var nuGetPath = new NuGetPath(logger); + var nuGetVersion = new NuGetVersion(newPackageVersion); + var packageSource = new PackageSource(NuGetConstants.V3FeedUrl); + + var restoreCommand = new NuGetFileRestoreCommand(logger, nuGetPath, monoExecutor, externalProcess); + var updateCommand = new NuGetUpdatePackageCommand(logger, nuGetPath, monoExecutor, externalProcess); var packageToUpdate = new PackageInProject("Microsoft.AspNet.WebApi.Client", oldPackageVersion, - new PackagePath(workDirectory, testProject, PackageReferenceType.PackagesConfig)); + new PackagePath(workDirectory, testProject, PackageReferenceType.PackagesConfig)); + + await restoreCommand.Invoke(packageToUpdate, nuGetVersion, packageSource, NuGetSources.GlobalFeed); - await command.Invoke(packageToUpdate, new NuGetVersion(newPackageVersion), - new PackageSource(NuGetConstants.V3FeedUrl), NuGetSources.GlobalFeed); + await updateCommand.Invoke(packageToUpdate, nuGetVersion, packageSource, NuGetSources.GlobalFeed); var contents = await File.ReadAllTextAsync(packagesConfigPath); - Assert.That(contents, Does.Contain(expectedPackageString.Replace("{packageVersion}", newPackageVersion, StringComparison.OrdinalIgnoreCase))); - Assert.That(contents, Does.Not.Contain(expectedPackageString.Replace("{packageVersion}", oldPackageVersion, StringComparison.OrdinalIgnoreCase))); + Assert.That(contents, + Does.Contain(expectedPackageString.Replace("{packageVersion}", newPackageVersion, + StringComparison.OrdinalIgnoreCase))); + Assert.That(contents, + Does.Not.Contain(expectedPackageString.Replace("{packageVersion}", oldPackageVersion, + StringComparison.OrdinalIgnoreCase))); } private static IFolder UniqueTemporaryFolder() diff --git a/NuKeeper.Update.Tests/Process/MonoExecutorTests.cs b/NuKeeper.Update.Tests/Process/MonoExecutorTests.cs new file mode 100644 index 000000000..076ef32bd --- /dev/null +++ b/NuKeeper.Update.Tests/Process/MonoExecutorTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using NSubstitute; +using NuKeeper.Abstractions.Logging; +using NuKeeper.Update.Process; +using NuKeeper.Update.ProcessRunner; +using NUnit.Framework; + +namespace NuKeeper.Update.Tests.Process +{ + [TestFixture] + public class MonoExecutorTests + { + [TestCase(0, true)] + [TestCase(1, false)] + public async Task WhenCallingCanRun_ShouldCheckExternalProcessResult(int exitCode, bool expectedCanExecute) + { + var nuKeeperLogger = Substitute.For(); + var externalProcess = Substitute.For(); + + externalProcess.Run("","mono","--version",false). + Returns(new ProcessOutput("","",exitCode)); + + var monoExecutor = new MonoExecutor(nuKeeperLogger, externalProcess); + + var canRun = await monoExecutor.CanRun(); + + Assert.AreEqual(expectedCanExecute, canRun); + } + + [Test] + public async Task WhenCallingCanRun_ShouldOnlyCallExternalProcessOnce() + { + var nuKeeperLogger = Substitute.For(); + var externalProcess = Substitute.For(); + + externalProcess.Run("","mono","--version",false). + Returns(new ProcessOutput("","",0)); + + var monoExecutor = new MonoExecutor(nuKeeperLogger, externalProcess); + + await monoExecutor.CanRun(); + await monoExecutor.CanRun(); + await monoExecutor.CanRun(); + + await externalProcess.Received(1).Run( + "", + "mono", + "--version", + false); + } + + [Test] + public void WhenCallingRun_ShouldThrowIfMonoWasNotFound() + { + var nuKeeperLogger = Substitute.For(); + var externalProcess = Substitute.For(); + + externalProcess.Run("","mono","--version",false). + Returns(new ProcessOutput("","",1)); + + var monoExecutor = new MonoExecutor(nuKeeperLogger, externalProcess); + + Assert.ThrowsAsync(async () => + await monoExecutor.Run("wd", "command", "args", true)); + } + + [Test] + public async Task WhenCallingRun_ShouldPassArgumentToUnderlyingExternalProcess() + { + var nuKeeperLogger = Substitute.For(); + var externalProcess = Substitute.For(); + + externalProcess.Run("","mono","--version",false). + Returns(new ProcessOutput("","",0)); + + var monoExecutor = new MonoExecutor(nuKeeperLogger, externalProcess); + await monoExecutor.Run("wd", "command", "args", true); + + await externalProcess.Received(1).Run("wd", "mono", "command args", true); + } + } +} diff --git a/NuKeeper.Update/Process/IMonoExecutor.cs b/NuKeeper.Update/Process/IMonoExecutor.cs new file mode 100644 index 000000000..014e9f795 --- /dev/null +++ b/NuKeeper.Update/Process/IMonoExecutor.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using NuKeeper.Update.ProcessRunner; + +namespace NuKeeper.Update.Process +{ + public interface IMonoExecutor: IExternalProcess + { + Task CanRun(); + } +} diff --git a/NuKeeper.Update/Process/MonoExecutor.cs b/NuKeeper.Update/Process/MonoExecutor.cs new file mode 100644 index 000000000..f2a15ada2 --- /dev/null +++ b/NuKeeper.Update/Process/MonoExecutor.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using NuGet.Common; +using NuKeeper.Abstractions.Logging; +using NuKeeper.Update.ProcessRunner; + +namespace NuKeeper.Update.Process +{ + public class MonoExecutor : IMonoExecutor + { + private readonly INuKeeperLogger _logger; + private readonly IExternalProcess _externalProcess; + + private readonly AsyncLazy _checkMono; + + public MonoExecutor(INuKeeperLogger logger, IExternalProcess externalProcess) + { + _logger = logger; + _externalProcess = externalProcess; + _checkMono = new AsyncLazy(CheckMonoExists); + } + + public async Task CanRun() + { + return await _checkMono; + } + + public async Task Run(string workingDirectory, string command, string arguments, bool ensureSuccess) + { + _logger.Normal($"Using Mono to run '{command}'"); + + if (!await CanRun()) + { + _logger.Error($"Cannot run '{command}' on Mono since Mono installation was not found"); + throw new InvalidOperationException("Mono installation was not found"); + } + + return await _externalProcess.Run(workingDirectory, "mono", $"{command} {arguments}", ensureSuccess); + } + + private async Task CheckMonoExists() + { + var result = await _externalProcess.Run("", "mono", "--version", false); + + return result.Success; + } + } +} diff --git a/NuKeeper.Update/Process/NuGetFileRestoreCommand.cs b/NuKeeper.Update/Process/NuGetFileRestoreCommand.cs index dba6a260d..02ae249db 100644 --- a/NuKeeper.Update/Process/NuGetFileRestoreCommand.cs +++ b/NuKeeper.Update/Process/NuGetFileRestoreCommand.cs @@ -14,15 +14,18 @@ public class NuGetFileRestoreCommand : IFileRestoreCommand { private readonly INuKeeperLogger _logger; private readonly INuGetPath _nuGetPath; + private readonly IMonoExecutor _monoExecutor; private readonly IExternalProcess _externalProcess; public NuGetFileRestoreCommand( INuKeeperLogger logger, INuGetPath nuGetPath, + IMonoExecutor monoExecutor, IExternalProcess externalProcess) { _logger = logger; _nuGetPath = nuGetPath; + _monoExecutor = monoExecutor; _externalProcess = externalProcess; } @@ -30,17 +33,11 @@ public async Task Invoke(FileInfo file, NuGetSources sources) { _logger.Normal($"Nuget restore on {file.DirectoryName} {file.Name}"); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - _logger.Normal("Cannot run NuGet.exe file restore as OS Platform is not Windows"); - return; - } - var nuget = _nuGetPath.Executable; if (string.IsNullOrWhiteSpace(nuget)) { - _logger.Normal("Cannot find NuGet exe for solution restore"); + _logger.Normal("Cannot find NuGet.exe for solution restore"); return; } @@ -48,8 +45,29 @@ public async Task Invoke(FileInfo file, NuGetSources sources) var restoreCommand = $"restore {file.Name} {sourcesCommandLine} -NonInteractive"; - var processOutput = await _externalProcess.Run(file.DirectoryName, nuget, - restoreCommand, ensureSuccess: false); + ProcessOutput processOutput; + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (await _monoExecutor.CanRun()) + { + processOutput = await _monoExecutor.Run(file.DirectoryName, + nuget, + restoreCommand, + ensureSuccess: false); + } + else + { + _logger.Error("Cannot run NuGet.exe. It requires either Windows OS Platform or Mono installation"); + return; + } + } + else + { + processOutput = await _externalProcess.Run(file.DirectoryName, + nuget, + restoreCommand, + ensureSuccess: false); + } if (processOutput.Success) { @@ -57,7 +75,8 @@ public async Task Invoke(FileInfo file, NuGetSources sources) } else { - _logger.Detailed($"Nuget restore failed on {file.DirectoryName} {file.Name}:\n{processOutput.Output}\n{processOutput.ErrorOutput}"); + _logger.Detailed( + $"Nuget restore failed on {file.DirectoryName} {file.Name}:\n{processOutput.Output}\n{processOutput.ErrorOutput}"); } } diff --git a/NuKeeper.Update/Process/NuGetUpdatePackageCommand.cs b/NuKeeper.Update/Process/NuGetUpdatePackageCommand.cs index e528c734f..c5a6411f2 100644 --- a/NuKeeper.Update/Process/NuGetUpdatePackageCommand.cs +++ b/NuKeeper.Update/Process/NuGetUpdatePackageCommand.cs @@ -14,38 +14,50 @@ public class NuGetUpdatePackageCommand : INuGetUpdatePackageCommand private readonly IExternalProcess _externalProcess; private readonly INuKeeperLogger _logger; private readonly INuGetPath _nuGetPath; + private readonly IMonoExecutor _monoExecutor; public NuGetUpdatePackageCommand( INuKeeperLogger logger, INuGetPath nuGetPath, + IMonoExecutor monoExecutor, IExternalProcess externalProcess) { _logger = logger; _nuGetPath = nuGetPath; + _monoExecutor = monoExecutor; _externalProcess = externalProcess; } public async Task Invoke(PackageInProject currentPackage, NuGetVersion newVersion, PackageSource packageSource, NuGetSources allSources) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - _logger.Normal("Cannot run NuGet.exe package update as OS Platform is not Windows"); - return; - } - var projectPath = currentPackage.Path.Info.DirectoryName; var nuget = _nuGetPath.Executable; if (string.IsNullOrWhiteSpace(nuget)) { - _logger.Normal("Cannot find NuGet exe for package update"); + _logger.Normal("Cannot find NuGet.exe for package update"); return; } var sources = allSources.CommandLine("-Source"); var updateCommand = $"update packages.config -Id {currentPackage.Id} -Version {newVersion} {sources} -NonInteractive"; - await _externalProcess.Run(projectPath, nuget, updateCommand, true); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (await _monoExecutor.CanRun()) + { + await _monoExecutor.Run(projectPath, nuget, updateCommand, true); + } + else + { + _logger.Error("Cannot run NuGet.exe. It requires either Windows OS Platform or Mono installation"); + } + } + else + { + await _externalProcess.Run(projectPath, nuget, updateCommand, true); + } } } } diff --git a/NuKeeper/ContainerUpdateRegistration.cs b/NuKeeper/ContainerUpdateRegistration.cs index 1108e488a..f86e50d48 100644 --- a/NuKeeper/ContainerUpdateRegistration.cs +++ b/NuKeeper/ContainerUpdateRegistration.cs @@ -16,6 +16,7 @@ public static void Register(Container container) container.Register(); container.Register(); container.Register(); + container.Register(); container.Register(); container.Register(); } diff --git a/README.md b/README.md index 2f208f320..969ac0254 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Installation is very easy. Just run this command and the tool will be installed. Install: `dotnet tool install nukeeper --global` +> Note: NuKeeper has experimental support for running package updates on Linux/macOS. This functionality relies on Mono installation on local system. Please refer to https://www.mono-project.com/ for more information about how to install mono. + ### Platform support NuKeeper works for .NET Framework and for .NET Core projects. It also has the ability to target private NuGet feeds. diff --git a/site/content/basics/installation.md b/site/content/basics/installation.md index 9abbd38c4..934f449b1 100644 --- a/site/content/basics/installation.md +++ b/site/content/basics/installation.md @@ -20,6 +20,8 @@ Update NuKeeper with: dotnet tool update nukeeper --global ``` +> Note: NuKeeper has experimental support for running package updates on Linux/macOS. This functionality relies on Mono installation on local system. Please refer to https://www.mono-project.com/ for more information about how to install mono. + # Using NuKeeper Running `nukeeper` with no arguments shows the help.