diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 758ec0e23de..dff3b5cb572 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -2,11 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics; -using System.Formats.Tar; -using System.Globalization; -using System.IO.Compression; -using System.Runtime.InteropServices; using Aspire.Cli.Configuration; using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; @@ -26,6 +21,7 @@ internal sealed class UpdateCommand : BaseCommand private readonly IAppHostProjectFactory _projectFactory; private readonly ILogger _logger; private readonly ICliDownloader? _cliDownloader; + private readonly ICliInstaller? _cliInstaller; private readonly ICliUpdateNotifier _updateNotifier; private readonly IFeatures _features; private readonly IConfigurationService _configurationService; @@ -36,6 +32,7 @@ public UpdateCommand( IAppHostProjectFactory projectFactory, ILogger logger, ICliDownloader? cliDownloader, + ICliInstaller? cliInstaller, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, @@ -56,6 +53,7 @@ public UpdateCommand( _projectFactory = projectFactory; _logger = logger; _cliDownloader = cliDownloader; + _cliInstaller = cliInstaller; _updateNotifier = updateNotifier; _features = features; _configurationService = configurationService; @@ -285,10 +283,34 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella InteractionService.DisplayMessage("up_arrow", $"Updating to channel: {channel}"); // Download the latest CLI + InteractionService.DisplayMessage("package", "Downloading..."); var archivePath = await _cliDownloader!.DownloadLatestCliAsync(channel, cancellationToken); - // Extract and update to $HOME/.aspire/bin - await ExtractAndUpdateAsync(archivePath, cancellationToken); + // Install using shared installer - clean up old backups for explicit update + InteractionService.DisplayMessage("wrench", "Installing..."); + var result = await _cliInstaller!.InstallFromArchiveAsync(archivePath, cleanupBackups: true, cancellationToken); + + if (!result.Success) + { + InteractionService.DisplayError(result.ErrorMessage ?? "Installation failed."); + return ExitCodeConstants.InvalidCommand; + } + + InteractionService.DisplaySuccess($"Updated to version: {result.Version}"); + + // Clean up downloaded archive + try + { + var archiveDir = Path.GetDirectoryName(archivePath); + if (archiveDir is not null && Directory.Exists(archiveDir)) + { + Directory.Delete(archiveDir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } // Save the selected channel to global settings for future use with 'aspire new' and 'aspire init' // For stable channel, clear the setting to leave it blank (like the install scripts do) @@ -318,246 +340,4 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella return ExitCodeConstants.InvalidCommand; } } - - private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken) - { - // Install to the same directory as the current CLI executable - var currentExePath = Environment.ProcessPath; - if (string.IsNullOrEmpty(currentExePath)) - { - throw new InvalidOperationException("Unable to determine current CLI location."); - } - - var installDir = Path.GetDirectoryName(currentExePath); - if (string.IsNullOrEmpty(installDir)) - { - throw new InvalidOperationException($"Unable to determine installation directory from: {currentExePath}"); - } - - var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire"; - var targetExePath = Path.Combine(installDir, exeName); - var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName; - - try - { - // Extract archive - InteractionService.DisplayMessage("package", "Extracting new CLI..."); - await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); - - // Find the aspire executable in the extracted files - var newExePath = Path.Combine(tempExtractDir, exeName); - if (!File.Exists(newExePath)) - { - throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}"); - } - - // Backup current executable if it exists - var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var backupPath = $"{targetExePath}.old.{unixTimestamp}"; - if (File.Exists(targetExePath)) - { - InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI..."); - _logger.LogDebug("Creating backup: {BackupPath}", backupPath); - - // Clean up old backup files - CleanupOldBackupFiles(targetExePath); - - // Rename current executable to .old.[timestamp] - File.Move(targetExePath, backupPath); - } - - try - { - // Copy new executable to install location - InteractionService.DisplayMessage("wrench", $"Installing new CLI to {installDir}..."); - File.Copy(newExePath, targetExePath, overwrite: true); - - // On Unix systems, ensure the executable bit is set - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - SetExecutablePermission(targetExePath); - } - - // Test the new executable and display its version - _logger.LogDebug("Testing new CLI executable and displaying version"); - var newVersion = await GetNewVersionAsync(targetExePath, cancellationToken); - if (newVersion is null) - { - throw new InvalidOperationException("New CLI executable failed verification test."); - } - - // If we get here, the update was successful, clean up old backups - CleanupOldBackupFiles(targetExePath); - - // Display helpful message about PATH - if (!IsInPath(installDir)) - { - InteractionService.DisplayMessage("information", $"Note: {installDir} is not in your PATH. Add it to use the updated CLI globally."); - } - } - catch - { - // If anything goes wrong, restore the backup - _logger.LogWarning("Update failed, restoring backup"); - if (File.Exists(backupPath)) - { - if (File.Exists(targetExePath)) - { - File.Delete(targetExePath); - } - File.Move(backupPath, targetExePath); - } - throw; - } - } - catch (UnauthorizedAccessException) - { - throw new UnauthorizedAccessException( - string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.NoWritePermissionToInstallDirectory, installDir)); - } - finally - { - // Clean up temp directories - CleanupDirectory(tempExtractDir); - CleanupDirectory(Path.GetDirectoryName(archivePath)!); - } - } - - private static bool IsInPath(string directory) - { - var pathEnv = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(pathEnv)) - { - return false; - } - - var pathSeparator = Path.PathSeparator; - var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries); - - return paths.Any(p => - string.Equals(Path.GetFullPath(p.Trim()), Path.GetFullPath(directory), - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal)); - } - - private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) - { - if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); - } - else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) - { - await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); - await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); - } - else - { - throw new NotSupportedException($"Unsupported archive format: {archivePath}"); - } - } - - private void SetExecutablePermission(string filePath) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - try - { - var mode = File.GetUnixFileMode(filePath); - mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - File.SetUnixFileMode(filePath, mode); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to set executable permission on {FilePath}", filePath); - } - } - } - - private async Task GetNewVersionAsync(string exePath, CancellationToken cancellationToken) - { - try - { - var psi = new ProcessStartInfo - { - FileName = exePath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is null) - { - return null; - } - - var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - - if (process.ExitCode == 0) - { - var version = output.Trim(); - InteractionService.DisplaySuccess($"Updated to version: {version}"); - return version; - } - - return null; - } - catch - { - return null; - } - } - - internal void CleanupOldBackupFiles(string targetExePath) - { - try - { - var directory = Path.GetDirectoryName(targetExePath); - if (string.IsNullOrEmpty(directory)) - { - return; - } - - var exeName = Path.GetFileName(targetExePath); - var searchPattern = $"{exeName}.old.*"; - - var oldBackupFiles = Directory.GetFiles(directory, searchPattern); - foreach (var backupFile in oldBackupFiles) - { - try - { - File.Delete(backupFile); - _logger.LogDebug("Deleted old backup file: {BackupFile}", backupFile); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to delete old backup file: {BackupFile}", backupFile); - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to cleanup old backup files for: {TargetExePath}", targetExePath); - } - } - - private void CleanupDirectory(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory); - } - } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 9e13f81b89d..1cf2ec420ff 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -176,6 +176,8 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); // Git repository operations. @@ -372,6 +374,10 @@ public static async Task Main(string[] args) await app.StartAsync().ConfigureAwait(false); + // Start background auto-update (fire-and-forget) + var autoUpdater = app.Services.GetRequiredService(); + autoUpdater.StartBackgroundUpdate(args ?? []); + var rootCommand = app.Services.GetRequiredService(); var invokeConfig = new InvocationConfiguration() { @@ -380,7 +386,7 @@ public static async Task Main(string[] args) var telemetry = app.Services.GetRequiredService(); using var activity = telemetry.ActivitySource.StartActivity(); - var exitCode = await rootCommand.Parse(args).InvokeAsync(invokeConfig, cts.Token); + var exitCode = await rootCommand.Parse(args ?? []).InvokeAsync(invokeConfig, cts.Token); await app.StopAsync().ConfigureAwait(false); diff --git a/src/Aspire.Cli/Utils/AutoUpdater.cs b/src/Aspire.Cli/Utils/AutoUpdater.cs new file mode 100644 index 00000000000..f92aa361af3 --- /dev/null +++ b/src/Aspire.Cli/Utils/AutoUpdater.cs @@ -0,0 +1,381 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using Aspire.Cli.Configuration; +using Aspire.Cli.NuGet; +using Aspire.Cli.Packaging; +using Aspire.Hosting; +using Aspire.Shared; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +/// +/// Handles automatic background updates of the CLI. +/// +internal interface IAutoUpdater +{ + /// + /// Starts a background update check and install if an update is available. + /// This is fire-and-forget - if the CLI exits before update completes, no harm. + /// + /// Command line arguments to check if running update --self. + void StartBackgroundUpdate(string[] args); +} + +internal sealed class AutoUpdater( + ILogger logger, + IConfiguration configuration, + IConfigurationService configurationService, + IPackagingService packagingService, + INuGetPackageCache nuGetPackageCache, + ICliInstaller cliInstaller, + CliExecutionContext executionContext, + TimeProvider timeProvider) : IAutoUpdater +{ + private const string AutoUpdateMutexName = "AspireCliAutoUpdate"; + private const string LastAutoUpdateCheckKey = "lastAutoUpdateCheck"; + private static readonly TimeSpan s_stableThrottleDuration = TimeSpan.FromHours(24); + + private static readonly HttpClient s_httpClient = new(); + + public void StartBackgroundUpdate(string[] args) + { + // Fire and forget - don't await + _ = TryUpdateInBackgroundAsync(args); + } + + private async Task TryUpdateInBackgroundAsync(string[] args) + { + try + { + // Check if running `aspire update --self` - skip auto-update to avoid conflicts + if (IsUpdateSelfCommand(args)) + { + logger.LogDebug("Auto-update skipped: running update --self command"); + return; + } + + // Check if auto-update is disabled via environment variable + if (IsAutoUpdateDisabled()) + { + logger.LogDebug("Auto-update is disabled via environment variable"); + return; + } + + // Check if running as dotnet tool (can't self-update) + if (IsRunningAsDotNetTool()) + { + logger.LogDebug("Auto-update skipped: running as dotnet tool"); + return; + } + + // Check if this is a PR or hive build (shouldn't auto-update) + if (IsPrOrHiveBuild()) + { + logger.LogDebug("Auto-update skipped: PR or hive build detected"); + return; + } + + // Get the configured channel + var channel = await GetConfiguredChannelAsync(); + if (channel is null) + { + logger.LogDebug("Auto-update skipped: no channel configured"); + return; + } + + // Check throttle for stable channel + if (!await ShouldCheckForUpdateAsync(channel)) + { + logger.LogDebug("Auto-update skipped: throttled for channel {Channel}", channel); + return; + } + + // Try to acquire mutex - if already held by another process, skip + using var mutex = new Mutex(false, AutoUpdateMutexName, out _); + bool acquired; + try + { + acquired = mutex.WaitOne(0); + } + catch (AbandonedMutexException) + { + // Another process held the mutex but crashed - we now own it + acquired = true; + } + + if (!acquired) + { + logger.LogDebug("Auto-update skipped: another update is in progress"); + return; + } + + try + { + await PerformUpdateAsync(channel, CancellationToken.None); + } + finally + { + mutex.ReleaseMutex(); + } + } + catch (Exception ex) + { + // Silent failure - log and continue + logger.LogDebug(ex, "Auto-update failed silently"); + } + } + + internal bool IsAutoUpdateDisabled() + { + var disabled = configuration[KnownConfigNames.CliAutoUpdateDisabled]; + return string.Equals(disabled, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(disabled, "1", StringComparison.Ordinal); + } + + internal static bool IsUpdateSelfCommand(string[] args) + { + // Check if args contain "update" and "--self" + var hasUpdate = args.Any(a => string.Equals(a, "update", StringComparison.OrdinalIgnoreCase)); + var hasSelf = args.Any(a => string.Equals(a, "--self", StringComparison.OrdinalIgnoreCase)); + return hasUpdate && hasSelf; + } + + internal static bool IsRunningAsDotNetTool() + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return false; + } + + var fileName = Path.GetFileNameWithoutExtension(processPath); + return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase); + } + + internal bool IsPrOrHiveBuild() + { + // Check if there are PR hives on this machine - if so, this is likely a dev machine + // running a PR build and shouldn't auto-update + var prHiveCount = executionContext.GetPrHiveCount(); + if (prHiveCount > 0) + { + logger.LogDebug("Detected {PrHiveCount} PR hives - assuming PR/dev build", prHiveCount); + return true; + } + + // Check if the current executable is in a hive directory + var currentExePath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(currentExePath)) + { + var hivesPath = executionContext.HivesDirectory.FullName; + if (currentExePath.StartsWith(hivesPath, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Current executable is in hives directory - assuming hive build"); + return true; + } + } + + return false; + } + + internal async Task GetConfiguredChannelAsync() + { + // Get channel from global settings + var channel = await configurationService.GetConfigurationAsync("channel", CancellationToken.None); + + // Default to stable if no channel is configured + return string.IsNullOrEmpty(channel) ? PackageChannelNames.Stable : channel; + } + + internal async Task ShouldCheckForUpdateAsync(string channel) + { + // Daily and staging channels: always check (no throttle) + if (string.Equals(channel, PackageChannelNames.Daily, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel, PackageChannelNames.Staging, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Stable channel: check once per 24 hours + var lastCheckKey = $"{LastAutoUpdateCheckKey}.{channel}"; + var lastCheckStr = await configurationService.GetConfigurationAsync(lastCheckKey, CancellationToken.None); + + if (string.IsNullOrEmpty(lastCheckStr)) + { + return true; + } + + if (DateTimeOffset.TryParse(lastCheckStr, out var lastCheck)) + { + var now = timeProvider.GetUtcNow(); + var elapsed = now - lastCheck; + + if (elapsed < s_stableThrottleDuration) + { + logger.LogDebug("Last update check was {Elapsed} ago, throttle duration is {ThrottleDuration}", elapsed, s_stableThrottleDuration); + return false; + } + } + + return true; + } + + private async Task PerformUpdateAsync(string channelName, CancellationToken cancellationToken) + { + logger.LogDebug("Checking for auto-update on channel {Channel}", channelName); + + // Record the check time (for throttling) + var lastCheckKey = $"{LastAutoUpdateCheckKey}.{channelName}"; + await configurationService.SetConfigurationAsync(lastCheckKey, timeProvider.GetUtcNow().ToString("O"), isGlobal: true, cancellationToken); + + // Check if an update is available + var currentVersion = PackageUpdateHelpers.GetCurrentPackageVersion(); + if (currentVersion is null) + { + logger.LogDebug("Unable to determine current CLI version"); + return; + } + + var availablePackages = await nuGetPackageCache.GetCliPackagesAsync( + workingDirectory: executionContext.WorkingDirectory, + prerelease: true, + nugetConfigFile: null, + cancellationToken: cancellationToken); + + var newerVersion = PackageUpdateHelpers.GetNewerVersion(logger, currentVersion, availablePackages); + if (newerVersion is null) + { + logger.LogDebug("No newer version available (current: {CurrentVersion})", currentVersion); + return; + } + + logger.LogDebug("Newer version available: {NewerVersion} (current: {CurrentVersion})", newerVersion, currentVersion); + + // Get channel info for download URL + var channels = await packagingService.GetChannelsAsync(cancellationToken); + var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase)); + + if (channel is null || string.IsNullOrEmpty(channel.CliDownloadBaseUrl)) + { + logger.LogDebug("Channel {ChannelName} does not support CLI downloads", channelName); + return; + } + + // Download and install + var archivePath = await DownloadCliArchiveAsync(channel.CliDownloadBaseUrl, cancellationToken); + if (archivePath is null) + { + return; + } + + try + { + // Use shared installer - don't clean up backups for auto-update (keep safety net) + var result = await cliInstaller.InstallFromArchiveAsync(archivePath, cleanupBackups: false, cancellationToken); + + if (result.Success) + { + logger.LogDebug("Auto-update completed successfully to version {Version}", result.Version); + } + else + { + logger.LogDebug("Auto-update failed: {ErrorMessage}", result.ErrorMessage); + } + } + finally + { + // Clean up downloaded archive + try + { + var archiveDir = Path.GetDirectoryName(archivePath); + if (archiveDir is not null && Directory.Exists(archiveDir)) + { + Directory.Delete(archiveDir, recursive: true); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to clean up archive directory"); + } + } + } + + private async Task DownloadCliArchiveAsync(string baseUrl, CancellationToken cancellationToken) + { + var (os, arch) = CliPlatformDetector.DetectPlatform(); + var runtimeIdentifier = $"{os}-{arch}"; + var extension = os == "win" ? "zip" : "tar.gz"; + var archiveFilename = $"aspire-cli-{runtimeIdentifier}.{extension}"; + var checksumFilename = $"{archiveFilename}.sha512"; + var archiveUrl = $"{baseUrl}/{archiveFilename}"; + var checksumUrl = $"{baseUrl}/{checksumFilename}"; + + var tempDir = Directory.CreateTempSubdirectory("aspire-cli-autoupdate").FullName; + + try + { + var archivePath = Path.Combine(tempDir, archiveFilename); + var checksumPath = Path.Combine(tempDir, checksumFilename); + + // Download archive and checksum + logger.LogDebug("Downloading CLI from {Url}", archiveUrl); + await DownloadFileAsync(archiveUrl, archivePath, cancellationToken); + await DownloadFileAsync(checksumUrl, checksumPath, cancellationToken); + + // Validate checksum + await ValidateChecksumAsync(archivePath, checksumPath, cancellationToken); + + return archivePath; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to download CLI archive"); + + // Clean up temp directory on failure + try + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + + return null; + } + } + + private static async Task DownloadFileAsync(string url, string outputPath, CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(10)); + + using var response = await s_httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream, cts.Token); + } + + private static async Task ValidateChecksumAsync(string archivePath, string checksumPath, CancellationToken cancellationToken) + { + var expectedChecksum = (await File.ReadAllTextAsync(checksumPath, cancellationToken)).Trim().ToLowerInvariant(); + + using var sha512 = SHA512.Create(); + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var hashBytes = await sha512.ComputeHashAsync(fileStream, cancellationToken); + var actualChecksum = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + if (expectedChecksum != actualChecksum) + { + throw new InvalidOperationException("Checksum validation failed"); + } + } +} diff --git a/src/Aspire.Cli/Utils/CliDownloader.cs b/src/Aspire.Cli/Utils/CliDownloader.cs index 4d8b86d4287..2cf3505d7ce 100644 --- a/src/Aspire.Cli/Utils/CliDownloader.cs +++ b/src/Aspire.Cli/Utils/CliDownloader.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; -using System.Runtime.InteropServices; using System.Security.Cryptography; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -46,7 +44,7 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio var baseUrl = channel.CliDownloadBaseUrl; - var (os, arch) = DetectPlatform(); + var (os, arch) = CliPlatformDetector.DetectPlatform(); var runtimeIdentifier = $"{os}-{arch}"; var extension = os == "win" ? "zip" : "tar.gz"; var archiveFilename = $"aspire-cli-{runtimeIdentifier}.{extension}"; @@ -100,75 +98,6 @@ public async Task DownloadLatestCliAsync(string channelName, Cancellatio } } - private static (string os, string arch) DetectPlatform() - { - var os = DetectOperatingSystem(); - var arch = DetectArchitecture(); - return (os, arch); - } - - private static string DetectOperatingSystem() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "win"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - // Check if it's musl-based (Alpine, etc.) - try - { - var lddPath = "/usr/bin/ldd"; - if (File.Exists(lddPath)) - { - var psi = new ProcessStartInfo - { - FileName = lddPath, - Arguments = "--version", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var process = Process.Start(psi); - if (process is not null) - { - var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); - process.WaitForExit(); - if (output.Contains("musl", StringComparison.OrdinalIgnoreCase)) - { - return "linux-musl"; - } - } - } - } - catch - { - // Fall back to regular linux - } - return "linux"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return "osx"; - } - else - { - throw new PlatformNotSupportedException($"Unsupported operating system: {RuntimeInformation.OSDescription}"); - } - } - - private static string DetectArchitecture() - { - var arch = RuntimeInformation.ProcessArchitecture; - return arch switch - { - Architecture.X64 => "x64", - Architecture.X86 => "x86", - Architecture.Arm64 => "arm64", - _ => throw new PlatformNotSupportedException($"Unsupported architecture: {arch}") - }; - } - private static async Task DownloadFileAsync(string url, string outputPath, int timeoutSeconds, CancellationToken cancellationToken) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); diff --git a/src/Aspire.Cli/Utils/CliInstaller.cs b/src/Aspire.Cli/Utils/CliInstaller.cs new file mode 100644 index 00000000000..c699e510786 --- /dev/null +++ b/src/Aspire.Cli/Utils/CliInstaller.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +/// +/// Result of a CLI installation operation. +/// +internal sealed record CliInstallResult +{ + public bool Success { get; init; } + public string? Version { get; init; } + public string? ErrorMessage { get; init; } + public string? BackupPath { get; init; } + + public static CliInstallResult Succeeded(string version) => new() { Success = true, Version = version }; + public static CliInstallResult Failed(string errorMessage) => new() { Success = false, ErrorMessage = errorMessage }; +} + +/// +/// Shared utility for installing CLI updates. Used by both UpdateCommand (interactive) and AutoUpdater (background). +/// +internal interface ICliInstaller +{ + /// + /// Installs a new CLI from an archive file. + /// + /// Path to the downloaded archive (zip or tar.gz). + /// Whether to clean up old backup files after successful install. + /// Cancellation token. + /// Result indicating success/failure and the new version if successful. + Task InstallFromArchiveAsync(string archivePath, bool cleanupBackups, CancellationToken cancellationToken); +} + +internal sealed class CliInstaller(ILogger logger) : ICliInstaller +{ + public async Task InstallFromArchiveAsync(string archivePath, bool cleanupBackups, CancellationToken cancellationToken) + { + var currentExePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(currentExePath)) + { + return CliInstallResult.Failed("Unable to determine current CLI location."); + } + + var installDir = Path.GetDirectoryName(currentExePath); + if (string.IsNullOrEmpty(installDir)) + { + return CliInstallResult.Failed($"Unable to determine installation directory from: {currentExePath}"); + } + + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire"; + var targetExePath = Path.Combine(installDir, exeName); + var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName; + string? backupPath = null; + + try + { + // Extract archive + logger.LogDebug("Extracting archive {ArchivePath} to {TempDir}", archivePath, tempExtractDir); + await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); + + // Find the aspire executable in the extracted files + var newExePath = Path.Combine(tempExtractDir, exeName); + if (!File.Exists(newExePath)) + { + return CliInstallResult.Failed($"Extracted CLI executable not found: {newExePath}"); + } + + // Verify the new executable works BEFORE replacing the current one + logger.LogDebug("Verifying new executable in temp location"); + var tempVersion = await GetExecutableVersionAsync(newExePath, cancellationToken); + if (tempVersion is null) + { + return CliInstallResult.Failed("New CLI executable failed verification test."); + } + + // Clean up old backup files before creating a new one + if (cleanupBackups) + { + CleanupOldBackupFiles(targetExePath); + } + + // Backup current executable if it exists + if (File.Exists(targetExePath)) + { + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + backupPath = $"{targetExePath}.old.{unixTimestamp}"; + logger.LogDebug("Creating backup: {BackupPath}", backupPath); + File.Move(targetExePath, backupPath); + } + + try + { + // Copy new executable to install location + logger.LogDebug("Installing new CLI to {InstallDir}", installDir); + File.Copy(newExePath, targetExePath, overwrite: true); + + // On Unix systems, ensure the executable bit is set + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetExecutablePermission(targetExePath); + } + + // Verify the installed executable works + logger.LogDebug("Verifying installed executable"); + var installedVersion = await GetExecutableVersionAsync(targetExePath, cancellationToken); + if (installedVersion is null) + { + throw new InvalidOperationException("Installed CLI executable failed verification."); + } + + return CliInstallResult.Succeeded(installedVersion) with { BackupPath = backupPath }; + } + catch + { + // Restore backup on failure using atomic move with overwrite + logger.LogWarning("Installation failed, restoring backup"); + if (backupPath is not null && File.Exists(backupPath)) + { + try + { + File.Move(backupPath, targetExePath, overwrite: true); + } + catch (Exception restoreEx) + { + logger.LogError(restoreEx, "Failed to restore backup from {BackupPath}", backupPath); + } + } + throw; + } + } + finally + { + // Clean up temp directory + CleanupDirectory(tempExtractDir); + } + } + + private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); + } + else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); + await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); + } + else + { + throw new NotSupportedException($"Unsupported archive format: {archivePath}"); + } + } + + private void SetExecutablePermission(string filePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + try + { + var mode = File.GetUnixFileMode(filePath); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(filePath, mode); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to set executable permission on {FilePath}", filePath); + } + } + + private static async Task GetExecutableVersionAsync(string exePath, CancellationToken cancellationToken) + { + try + { + var psi = new ProcessStartInfo + { + FileName = exePath, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is null) + { + return null; + } + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode == 0) + { + return output.Trim(); + } + + return null; + } + catch + { + return null; + } + } + + internal void CleanupOldBackupFiles(string targetExePath) + { + try + { + var directory = Path.GetDirectoryName(targetExePath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + var exeName = Path.GetFileName(targetExePath); + var searchPattern = $"{exeName}.old.*"; + + var oldBackupFiles = Directory.GetFiles(directory, searchPattern); + foreach (var backupFile in oldBackupFiles) + { + try + { + File.Delete(backupFile); + logger.LogDebug("Deleted old backup file: {BackupFile}", backupFile); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to delete old backup file: {BackupFile}", backupFile); + } + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to cleanup old backup files for: {TargetExePath}", targetExePath); + } + } + + private void CleanupDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to clean up directory {Directory}", directory); + } + } +} diff --git a/src/Aspire.Cli/Utils/CliPlatformDetector.cs b/src/Aspire.Cli/Utils/CliPlatformDetector.cs new file mode 100644 index 00000000000..4defa85cea3 --- /dev/null +++ b/src/Aspire.Cli/Utils/CliPlatformDetector.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Aspire.Cli.Utils; + +/// +/// Detects the current platform for CLI downloads. +/// +internal static class CliPlatformDetector +{ + public static (string os, string arch) DetectPlatform() + { + var os = DetectOperatingSystem(); + var arch = DetectArchitecture(); + return (os, arch); + } + + private static string DetectOperatingSystem() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Check if it's musl-based (Alpine, etc.) + return IsMuslBased() ? "linux-musl" : "linux"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "osx"; + } + else + { + throw new PlatformNotSupportedException($"Unsupported operating system: {RuntimeInformation.OSDescription}"); + } + } + + private static string DetectArchitecture() + { + var arch = RuntimeInformation.ProcessArchitecture; + return arch switch + { + Architecture.X64 => "x64", + Architecture.X86 => "x86", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException($"Unsupported architecture: {arch}") + }; + } + + private static bool IsMuslBased() + { + try + { + var lddPath = "/usr/bin/ldd"; + if (File.Exists(lddPath)) + { + var psi = new ProcessStartInfo + { + FileName = lddPath, + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var process = Process.Start(psi); + if (process is not null) + { + var output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (output.Contains("musl", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + catch + { + // Fall back to regular linux + } + return false; + } +} diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 60a88ad88d9..b7158592b50 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -8,6 +8,7 @@ internal static class KnownConfigNames public const string AspNetCoreUrls = "ASPNETCORE_URLS"; public const string AllowUnsecuredTransport = "ASPIRE_ALLOW_UNSECURED_TRANSPORT"; public const string VersionCheckDisabled = "ASPIRE_VERSION_CHECK_DISABLED"; + public const string CliAutoUpdateDisabled = "ASPIRE_CLI_AUTO_UPDATE_DISABLED"; public const string DashboardMcpEndpointUrl = "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL"; public const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index ba25b2e8c60..85c76b35900 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Spectre.Console; namespace Aspire.Cli.Tests.Commands; @@ -90,10 +91,10 @@ public void CleanupOldBackupFiles_DeletesFilesMatchingPattern() File.WriteAllText(oldBackup2, "test"); File.WriteAllText(otherFile, "test"); - var updateCommand = CreateUpdateCommand(workspace); + var cliInstaller = CreateCliInstaller(); // Act - updateCommand.CleanupOldBackupFiles(targetExePath); + cliInstaller.CleanupOldBackupFiles(targetExePath); // Assert Assert.False(File.Exists(oldBackup1), "Old backup file should be deleted"); @@ -113,10 +114,10 @@ public void CleanupOldBackupFiles_HandlesInUseFilesGracefully() File.WriteAllText(oldBackup, "test"); using var fileStream = new FileStream(oldBackup, FileMode.Open, FileAccess.Read, FileShare.None); - var updateCommand = CreateUpdateCommand(workspace); + var cliInstaller = CreateCliInstaller(); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(targetExePath); + cliInstaller.CleanupOldBackupFiles(targetExePath); // On Windows, locked files cannot be deleted, so the file should still exist // On Mac/Linux, locked files can be deleted, so the file may be deleted @@ -136,10 +137,10 @@ public void CleanupOldBackupFiles_HandlesNonExistentDirectory() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var nonExistentPath = Path.Combine("C:", "NonExistent", "aspire.exe"); - var updateCommand = CreateUpdateCommand(workspace); + var cliInstaller = CreateCliInstaller(); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(nonExistentPath); + cliInstaller.CleanupOldBackupFiles(nonExistentPath); } [Fact] @@ -148,17 +149,15 @@ public void CleanupOldBackupFiles_HandlesEmptyDirectory() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var targetExePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe"); - var updateCommand = CreateUpdateCommand(workspace); + var cliInstaller = CreateCliInstaller(); // Act & Assert - should not throw exception - updateCommand.CleanupOldBackupFiles(targetExePath); + cliInstaller.CleanupOldBackupFiles(targetExePath); } - private UpdateCommand CreateUpdateCommand(TemporaryWorkspace workspace) + private static CliInstaller CreateCliInstaller() { - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - var provider = services.BuildServiceProvider(); - return provider.GetRequiredService(); + return new CliInstaller(NullLogger.Instance); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs b/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs new file mode 100644 index 00000000000..b5f934abb71 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AutoUpdaterTests.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; +using Aspire.Cli.NuGet; +using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace Aspire.Cli.Tests.Utils; + +public class AutoUpdaterTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void IsAutoUpdateDisabled_ReturnsTrueWhenSetToTrue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configValues = new Dictionary + { + ["ASPIRE_CLI_AUTO_UPDATE_DISABLED"] = "true" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.True(autoUpdater.IsAutoUpdateDisabled()); + } + + [Fact] + public void IsAutoUpdateDisabled_ReturnsTrueWhenSetTo1() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configValues = new Dictionary + { + ["ASPIRE_CLI_AUTO_UPDATE_DISABLED"] = "1" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.True(autoUpdater.IsAutoUpdateDisabled()); + } + + [Fact] + public void IsAutoUpdateDisabled_ReturnsFalseWhenNotSet() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configuration = new ConfigurationBuilder().Build(); + + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.False(autoUpdater.IsAutoUpdateDisabled()); + } + + [Fact] + public void IsAutoUpdateDisabled_ReturnsFalseWhenSetToFalse() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configValues = new Dictionary + { + ["ASPIRE_CLI_AUTO_UPDATE_DISABLED"] = "false" + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.False(autoUpdater.IsAutoUpdateDisabled()); + } + + [Theory] + [InlineData("update", "--self")] + [InlineData("UPDATE", "--SELF")] + [InlineData("Update", "--Self")] + [InlineData("--self", "update")] + public void IsUpdateSelfCommand_ReturnsTrueForUpdateSelfArgs(string arg1, string arg2) + { + Assert.True(AutoUpdater.IsUpdateSelfCommand([arg1, arg2])); + } + + [Theory] + [InlineData("update")] + [InlineData("--self")] + [InlineData("run")] + [InlineData("new", "--self")] + public void IsUpdateSelfCommand_ReturnsFalseForOtherArgs(params string[] args) + { + Assert.False(AutoUpdater.IsUpdateSelfCommand(args)); + } + + [Fact] + public void IsPrOrHiveBuild_ReturnsTrueWhenHivesExist() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a hive directory + var hivesDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives", "pr-12345"); + Directory.CreateDirectory(hivesDir); + + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.True(autoUpdater.IsPrOrHiveBuild()); + } + + [Fact] + public void IsPrOrHiveBuild_ReturnsFalseWhenNoHives() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + Assert.False(autoUpdater.IsPrOrHiveBuild()); + } + + [Fact] + public async Task GetConfiguredChannelAsync_ReturnsStableWhenNotSet() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + var channel = await autoUpdater.GetConfiguredChannelAsync(); + + Assert.Equal("stable", channel); + } + + [Theory] + [InlineData("daily")] + [InlineData("staging")] + public async Task GetConfiguredChannelAsync_ReturnsConfiguredChannel(string channelName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var testConfigService = new TestConfigurationService(workspace.WorkspaceRoot); + testConfigService.SetValue("channel", channelName); + + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration, configurationService: testConfigService); + + var channel = await autoUpdater.GetConfiguredChannelAsync(); + + Assert.Equal(channelName, channel); + } + + [Theory] + [InlineData("daily")] + [InlineData("Daily")] + [InlineData("DAILY")] + [InlineData("staging")] + [InlineData("Staging")] + public async Task ShouldCheckForUpdateAsync_ReturnsTrueForDailyAndStaging(string channelName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var testConfigService = new TestConfigurationService(workspace.WorkspaceRoot); + // Set a very recent check time - should still return true for daily/staging + testConfigService.SetValue($"lastAutoUpdateCheck.{channelName}", fakeTime.GetUtcNow().ToString("O")); + + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration, configurationService: testConfigService, timeProvider: fakeTime); + + var shouldCheck = await autoUpdater.ShouldCheckForUpdateAsync(channelName); + + Assert.True(shouldCheck); + } + + [Fact] + public async Task ShouldCheckForUpdateAsync_ReturnsFalseForStableWithin24Hours() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + + var testConfigService = new TestConfigurationService(workspace.WorkspaceRoot); + // Set check time to 1 hour ago + testConfigService.SetValue("lastAutoUpdateCheck.stable", fakeTime.GetUtcNow().AddHours(-1).ToString("O")); + + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration, configurationService: testConfigService, timeProvider: fakeTime); + + var shouldCheck = await autoUpdater.ShouldCheckForUpdateAsync("stable"); + + Assert.False(shouldCheck); + } + + [Fact] + public async Task ShouldCheckForUpdateAsync_ReturnsTrueForStableAfter24Hours() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + + var testConfigService = new TestConfigurationService(workspace.WorkspaceRoot); + // Set check time to 25 hours ago + testConfigService.SetValue("lastAutoUpdateCheck.stable", fakeTime.GetUtcNow().AddHours(-25).ToString("O")); + + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration, configurationService: testConfigService, timeProvider: fakeTime); + + var shouldCheck = await autoUpdater.ShouldCheckForUpdateAsync("stable"); + + Assert.True(shouldCheck); + } + + [Fact] + public async Task ShouldCheckForUpdateAsync_ReturnsTrueForStableWithNoLastCheck() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var configuration = new ConfigurationBuilder().Build(); + var autoUpdater = CreateAutoUpdater(workspace, configuration); + + var shouldCheck = await autoUpdater.ShouldCheckForUpdateAsync("stable"); + + Assert.True(shouldCheck); + } + + private static AutoUpdater CreateAutoUpdater( + TemporaryWorkspace workspace, + IConfiguration configuration, + IConfigurationService? configurationService = null, + TimeProvider? timeProvider = null) + { + var logger = NullLogger.Instance; + var nuGetPackageCache = new TestNuGetPackageCache(); + var packagingService = new TestPackagingService(nuGetPackageCache); + + var hivesDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + var cacheDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache")); + var sdksDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "sdks")); + + var executionContext = new CliExecutionContext( + workspace.WorkspaceRoot, + hivesDirectory, + cacheDirectory, + sdksDirectory); + + configurationService ??= new TestConfigurationService(workspace.WorkspaceRoot); + timeProvider ??= TimeProvider.System; + var cliInstaller = new CliInstaller(NullLogger.Instance); + + return new AutoUpdater( + logger, + configuration, + configurationService, + packagingService, + nuGetPackageCache, + cliInstaller, + executionContext, + timeProvider); + } +} + +internal sealed class TestConfigurationService : IConfigurationService +{ + private readonly Dictionary _values = new(); + private readonly DirectoryInfo _workingDirectory; + + public TestConfigurationService(DirectoryInfo workingDirectory) + { + _workingDirectory = workingDirectory; + } + + public void SetValue(string key, string? value) + { + _values[key] = value; + } + + public Task GetConfigurationAsync(string key, CancellationToken cancellationToken = default) + { + _values.TryGetValue(key, out var value); + return Task.FromResult(value); + } + + public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default) + { + _values[key] = value; + return Task.CompletedTask; + } + + public Task DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_values.Remove(key)); + } + + public Task> GetAllConfigurationAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_values.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value!)); + } + + public Task> GetLocalConfigurationAsync(CancellationToken cancellationToken = default) + { + return GetAllConfigurationAsync(cancellationToken); + } + + public Task> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default) + { + return GetAllConfigurationAsync(cancellationToken); + } + + public string GetSettingsFilePath(bool isGlobal) + { + return Path.Combine(_workingDirectory.FullName, ".aspire", isGlobal ? "globalsettings.json" : "settings.json"); + } +} + +internal sealed class TestPackagingService : IPackagingService +{ + private readonly INuGetPackageCache _nuGetPackageCache; + + public TestPackagingService(INuGetPackageCache nuGetPackageCache) + { + _nuGetPackageCache = nuGetPackageCache; + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken = default) + { + IEnumerable channels = + [ + PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, null, _nuGetPackageCache, false, "https://example.com/stable"), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Prerelease, null, _nuGetPackageCache, false, "https://example.com/staging"), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, null, _nuGetPackageCache, false, "https://example.com/daily") + ]; + return Task.FromResult(channels); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index f1a037d8319..b689fdb83bf 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -98,6 +98,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.DiskCacheFactory); services.AddSingleton(options.CliHostEnvironmentFactory); services.AddSingleton(options.CliDownloaderFactory); + services.AddSingleton(options.CliInstallerFactory); services.AddSingleton(); services.AddSingleton(options.ProjectUpdaterFactory); services.AddSingleton(); @@ -385,6 +386,12 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new TestCliDownloader(tmpDirectory); }; + public Func CliInstallerFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var logger = serviceProvider.GetRequiredService>(); + return new CliInstaller(logger); + }; + public Func AuxiliaryBackchannelMonitorFactory { get; set; } = (IServiceProvider serviceProvider) => { return new TestAuxiliaryBackchannelMonitor();