Skip to content

Commit

Permalink
Implement Redpoint.PackageManagement library (#68)
Browse files Browse the repository at this point in the history
* Implement Redpoint.PackageManagement library

* Add ProgressMonitor to test dependency injection
  • Loading branch information
hach-que authored Dec 1, 2024
1 parent 89d7f13 commit 94879c5
Show file tree
Hide file tree
Showing 16 changed files with 432 additions and 99 deletions.
159 changes: 159 additions & 0 deletions UET/Redpoint.PackageManagement/HomebrewPackageManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
namespace Redpoint.PackageManagement
{
using Microsoft.Extensions.Logging;
using Redpoint.PathResolution;
using Redpoint.ProcessExecution;
using Redpoint.ProgressMonitor.Utils;
using Redpoint.Reservation;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;

[SupportedOSPlatform("macos")]
internal class HomebrewPackageManager : IPackageManager
{
private readonly ILogger<HomebrewPackageManager> _logger;
private readonly IPathResolver _pathResolver;
private readonly IProcessExecutor _processExecutor;
private readonly ISimpleDownloadProgress _simpleDownloadProgress;
private readonly IGlobalMutexReservationManager _globalMutexReservationManager;

public HomebrewPackageManager(
ILogger<HomebrewPackageManager> logger,
IPathResolver pathResolver,
IProcessExecutor processExecutor,
ISimpleDownloadProgress simpleDownloadProgress,
IReservationManagerFactory reservationManagerFactory)
{
_logger = logger;
_pathResolver = pathResolver;
_processExecutor = processExecutor;
_simpleDownloadProgress = simpleDownloadProgress;
_globalMutexReservationManager = reservationManagerFactory.CreateGlobalMutexReservationManager();
}

private async Task<string> FindHomebrewOrInstallItAsync(CancellationToken cancellationToken)
{
try
{
return await _pathResolver.ResolveBinaryPath("brew").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
}

await using (await _globalMutexReservationManager.ReserveExactAsync("HomebrewInstall", cancellationToken))
{
try
{
return await _pathResolver.ResolveBinaryPath("brew").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
}

if (File.Exists("/opt/homebrew/bin/brew"))
{
return "/opt/homebrew/bin/brew";
}

_logger.LogInformation($"Downloading Homebrew...");
using var client = new HttpClient();

var targetPath = Path.Combine(Path.GetTempPath(), "install.sh");
using (var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write))
{
await _simpleDownloadProgress.DownloadAndCopyToStreamAsync(
client,
new Uri("https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"),
async stream => await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
}
File.SetUnixFileMode(targetPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);

_logger.LogInformation($"Installing Homebrew...");
await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = @"/bin/bash",
Arguments = new LogicalProcessArgument[]
{
"-c",
targetPath,
},
WorkingDirectory = Path.GetTempPath(),
EnvironmentVariables = new Dictionary<string, string>
{
{ "NONINTERACTIVE", "1" }
},
}, CaptureSpecification.Passthrough, cancellationToken).ConfigureAwait(false);

return "/opt/homebrew/bin/brew";
}
}

public async Task InstallOrUpgradePackageToLatestAsync(string packageId, CancellationToken cancellationToken)
{
var homebrew = await FindHomebrewOrInstallItAsync(cancellationToken).ConfigureAwait(false);

await using (await _globalMutexReservationManager.TryReserveExactAsync($"PackageInstall-{packageId}").ConfigureAwait(false))
{
_logger.LogInformation($"Checking if {packageId} is installed...");
var exitCode = await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = homebrew,
Arguments = new LogicalProcessArgument[]
{
"list",
packageId,
},
WorkingDirectory = Path.GetTempPath(),
EnvironmentVariables = new Dictionary<string, string>
{
{ "NONINTERACTIVE", "1" }
},
}, CaptureSpecification.Silence, cancellationToken).ConfigureAwait(false);

if (exitCode == 0)
{
_logger.LogInformation($"Ensuring {packageId} is up-to-date...");
await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = homebrew,
Arguments = [
"upgrade",
"git",
],
EnvironmentVariables = new Dictionary<string, string>
{
{ "NONINTERACTIVE", "1" }
},
},
CaptureSpecification.Passthrough,
CancellationToken.None).ConfigureAwait(false);
}
else
{
_logger.LogInformation($"Installing {packageId}...");
await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = homebrew,
Arguments = [
"install",
"git",
],
EnvironmentVariables = new Dictionary<string, string>
{
{ "NONINTERACTIVE", "1" }
},
},
CaptureSpecification.Passthrough,
CancellationToken.None).ConfigureAwait(false);
}
};
}
}
}
16 changes: 16 additions & 0 deletions UET/Redpoint.PackageManagement/IPackageManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Redpoint.PackageManagement
{
using System.Threading;

public interface IPackageManager
{
/// <summary>
/// Installs or upgrades the target package to the latest version.
/// </summary>
/// <param name="packageId">The package ID (platform dependent).</param>
/// <returns>An asynchronous task that can be awaited on.</returns>
Task InstallOrUpgradePackageToLatestAsync(
string packageId,
CancellationToken cancellationToken);
}
}
19 changes: 19 additions & 0 deletions UET/Redpoint.PackageManagement/NullPackageManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Redpoint.PackageManagement
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

internal class NullPackageManager : IPackageManager
{
public Task InstallOrUpgradePackageToLatestAsync(
string packageId,
CancellationToken cancellationToken)
{
throw new PlatformNotSupportedException("This platform does not support package management.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace Redpoint.PackageManagement
{
using Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Provides registration functions to register an implementation of <see cref="IPackageManager"/> into a <see cref="IServiceCollection"/>.
/// </summary>
public static class PathResolutionServiceExtensions
{
/// <summary>
/// Add package management services (the <see cref="IPackageManager"/> service) into the service collection.
/// </summary>
/// <param name="services">The service collection to register an implementation of <see cref="IPackageManager"/> to.</param>
public static void AddPackageManagement(this IServiceCollection services)
{
if (OperatingSystem.IsWindows())
{
services.AddSingleton<IPackageManager, WinGetPackageManager>();
}
else if (OperatingSystem.IsMacOS())
{
services.AddSingleton<IPackageManager, HomebrewPackageManager>();
}
else
{
services.AddSingleton<IPackageManager, NullPackageManager>();
}
}
}
}
18 changes: 18 additions & 0 deletions UET/Redpoint.PackageManagement/Redpoint.PackageManagement.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="$(MSBuildThisFileDirectory)../Lib/Common.Build.props" />

<Import Project="$(MSBuildThisFileDirectory)../Lib/LibraryPackaging.Build.props" />
<PropertyGroup>
<Description>Provides APIs for installing, upgrading and uninstalling packages with WinGet and Homebrew.</Description>
<PackageTags>winget, homebrew, package, management</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Redpoint.Concurrency\Redpoint.Concurrency.csproj" />
<ProjectReference Include="..\Redpoint.PathResolution\Redpoint.PathResolution.csproj" />
<ProjectReference Include="..\Redpoint.ProcessExecution\Redpoint.ProcessExecution.csproj" />
<ProjectReference Include="..\Redpoint.ProgressMonitor\Redpoint.ProgressMonitor.csproj" />
<ProjectReference Include="..\Redpoint.Reservation\Redpoint.Reservation.csproj" />
</ItemGroup>

</Project>
138 changes: 138 additions & 0 deletions UET/Redpoint.PackageManagement/WinGetPackageManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace Redpoint.PackageManagement
{
using Microsoft.Extensions.Logging;
using Redpoint.Concurrency;
using Redpoint.PathResolution;
using Redpoint.ProcessExecution;
using Redpoint.ProgressMonitor.Utils;
using Redpoint.Reservation;
using System.Runtime.Versioning;
using System.Text;
using System.Threading;

[SupportedOSPlatform("windows")]
internal class WinGetPackageManager : IPackageManager
{
private readonly ILogger<WinGetPackageManager> _logger;
private readonly IPathResolver _pathResolver;
private readonly IProcessExecutor _processExecutor;
private readonly ISimpleDownloadProgress _simpleDownloadProgress;
private readonly IGlobalMutexReservationManager _globalMutexReservationManager;

public WinGetPackageManager(
ILogger<WinGetPackageManager> logger,
IPathResolver pathResolver,
IProcessExecutor processExecutor,
ISimpleDownloadProgress simpleDownloadProgress,
IReservationManagerFactory reservationManagerFactory)
{
_logger = logger;
_pathResolver = pathResolver;
_processExecutor = processExecutor;
_simpleDownloadProgress = simpleDownloadProgress;
_globalMutexReservationManager = reservationManagerFactory.CreateGlobalMutexReservationManager();
}

private async Task<string> FindPwshOrInstallItAsync(CancellationToken cancellationToken)
{
// Try to find PowerShell 7 via PATH. The WinGet CLI doesn't work under SYSTEM (even with absolute path) due to MSIX nonsense, but apparently the PowerShell scripts use a COM API that does?
try
{
return await _pathResolver.ResolveBinaryPath("pwsh").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
}

await using (await _globalMutexReservationManager.ReserveExactAsync("PwshInstall", cancellationToken))
{
try
{
return await _pathResolver.ResolveBinaryPath("pwsh").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
}

_logger.LogInformation($"Downloading PowerShell Core...");
using var client = new HttpClient();

var targetPath = Path.Combine(Path.GetTempPath(), "PowerShell-7.4.6-win-x64.msi");
using (var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write))
{
await _simpleDownloadProgress.DownloadAndCopyToStreamAsync(
client,
new Uri("https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi"),
async stream => await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
}

_logger.LogInformation($"Installing PowerShell Core...");
await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = @"C:\WINDOWS\system32\msiexec.exe",
Arguments = new LogicalProcessArgument[]
{
"/a",
targetPath,
"/quiet",
"/qn",
"ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1",
"ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1",
"ADD_PATH=1",
"DISABLE_TELEMETRY=1",
"USE_MU=1",
"ENABLE_MU=1"
},
WorkingDirectory = Path.GetTempPath()
}, CaptureSpecification.Passthrough, cancellationToken).ConfigureAwait(false);
}

return await _pathResolver.ResolveBinaryPath("pwsh").ConfigureAwait(false);
}

public async Task InstallOrUpgradePackageToLatestAsync(string packageId, CancellationToken cancellationToken)
{
var pwsh = await FindPwshOrInstallItAsync(cancellationToken).ConfigureAwait(false);

await using (await _globalMutexReservationManager.TryReserveExactAsync($"PackageInstall-{packageId}").ConfigureAwait(false))
{
_logger.LogInformation($"Ensuring {packageId} is installed and is up-to-date...");
var script =
$$"""
if ($null -eq (Get-InstalledModule -ErrorAction SilentlyContinue -Name {{packageId}})) {
Write-Host "Installing WinGet PowerShell module because it's not currently installed...";
Install-Module -Name Microsoft.WinGet.Client -Force;
}
$InstalledPackage = (Get-WinGetPackage -Id {{packageId}} -ErrorAction SilentlyContinue);
if ($null -eq $InstalledPackage) {
Write-Host "Installing {{packageId}} because it's not currently installed...";
Install-WinGetPackage -Id {{packageId}} -Mode Silent;
exit 0;
} elseif ($InstalledPackage.Version -ne (Find-WinGetPackage -Id {{packageId}}).Version) {
Write-Host "Updating {{packageId}} because it's not the latest version...";
Update-WinGetPackage -Id {{packageId}} -Mode Silent;
exit 0;
}
""";
var encodedScript = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));

await _processExecutor.ExecuteAsync(
new ProcessSpecification
{
FilePath = pwsh,
Arguments = [
"-NonInteractive",
"-OutputFormat",
"Text",
"-EncodedCommand",
encodedScript,
]
},
CaptureSpecification.Passthrough,
cancellationToken).ConfigureAwait(false);
}
}
}
}
10 changes: 10 additions & 0 deletions UET/Redpoint.Reservation/IGlobalMutexReservation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Redpoint.Reservation
{
/// <summary>
/// Represents a lock obtained on a global mutex. You must call <see cref="IAsyncDisposable.DisposeAsync"/>
/// once you are finished with the reservation.
/// </summary>
public interface IGlobalMutexReservation : IAsyncDisposable
{
}
}
Loading

0 comments on commit 94879c5

Please sign in to comment.