File: NuGet\BundleNuGetService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using NuGet.ProjectModel;
 
namespace Aspire.Cli.NuGet;
 
/// <summary>
/// Restores integration packages and creates package probe manifests.
/// </summary>
internal interface INuGetService
{
    /// <summary>
    /// Restores packages to the cache and creates a package probe manifest.
    /// </summary>
    /// <param name="packages">The packages to restore.</param>
    /// <param name="targetFramework">The target framework.</param>
    /// <param name="runtimeIdentifier">The runtime identifier used to prefer runtime-specific assets in the generated layout.</param>
    /// <param name="sources">Additional NuGet sources.</param>
    /// <param name="workingDirectory">Working directory for nuget.config discovery and for resolving the workspace-local restore cache. Required.</param>
    /// <param name="nugetConfigPath">An explicit NuGet.config file to use during restore.</param>
    /// <param name="ct">Cancellation token.</param>
    /// <returns>Path to the package probe manifest.</returns>
    Task<string> RestorePackagesAsync(
        IEnumerable<(string Id, string Version)> packages,
        string workingDirectory,
        string targetFramework = "net10.0",
        string? runtimeIdentifier = null,
        IEnumerable<string>? sources = null,
        string? nugetConfigPath = null,
        CancellationToken ct = default);
}
 
/// <summary>
/// Restores integration packages in-process through the NuGet client libraries.
/// </summary>
internal sealed class BundleNuGetService : INuGetService
{
    private readonly ILogger<BundleNuGetService> _logger;
    private readonly INuGetClient _nuGetClient;
 
    public BundleNuGetService(
        ILogger<BundleNuGetService> logger,
        INuGetClient nuGetClient)
    {
        _logger = logger;
        _nuGetClient = nuGetClient;
    }
 
    public async Task<string> RestorePackagesAsync(
        IEnumerable<(string Id, string Version)> packages,
        string workingDirectory,
        string targetFramework = "net10.0",
        string? runtimeIdentifier = null,
        IEnumerable<string>? sources = null,
        string? nugetConfigPath = null,
        CancellationToken ct = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
 
        var packageList = packages.ToList();
        if (packageList.Count == 0)
        {
            throw new ArgumentException("At least one package is required", nameof(packages));
        }
 
        var sourceList = sources?.ToArray();
 
        // The restore is now performed by this process, so the CLI's implementation is what must invalidate cached
        // manifests when it changes, just as the aspire-managed binary's size and timestamp did before.
        var packageHash = ComputePackageHash(
            packageList,
            targetFramework,
            runtimeIdentifier,
            GetRestoreToolPath(),
            sourceList);
        var restoreCacheDirectory = GetPackageRestoreCacheDirectory(workingDirectory);
        var restoreDirectory = Path.Combine(restoreCacheDirectory, packageHash);
        var objectDirectory = Path.Combine(restoreDirectory, "obj");
        var manifestPath = Path.Combine(restoreDirectory, IntegrationPackageProbeManifest.FileName);
        var lockPath = Path.Combine(restoreDirectory, "restore.lock");
 
        // The package cache is shared by every AppHost in the workspace. Serialize the
        // restore and manifest write so consumers never observe partially written files.
        using var fileLock = await FileLock.AcquireAsync(lockPath, ct).ConfigureAwait(false);
 
        if (File.Exists(manifestPath) && TryValidatePackageManifest(manifestPath, _logger))
        {
            _logger.LogDebug("Using cached package manifest at {Path}", manifestPath);
            return manifestPath;
        }
 
        Directory.CreateDirectory(objectDirectory);
        _logger.LogDebug("Restoring {Count} integration packages in-process", packageList.Count);
 
        // Failures keep the helper-era messages, which embed what the helper wrote to stderr, because
        // PrebuiltAppHostServer shows exception messages to users.
        try
        {
            await _nuGetClient.RestoreAsync(
                packageList,
                targetFramework,
                runtimeIdentifier,
                objectDirectory,
                sourceList ?? [],
                nugetConfigPath,
                workingDirectory,
                ct).ConfigureAwait(false);
        }
        catch (NuGetOperationException ex)
        {
            _logger.LogError("Package restore failed");
            _logger.LogError("Package restore stderr: {Error}", ex.Output);
            throw new InvalidOperationException($"Package restore failed: {ex.Output}", ex);
        }
 
        // The manifest is built from the assets file the restore just wrote, so asset selection
        // comes from NuGet rather than from a second walk over the package folders.
        try
        {
            await _nuGetClient.WriteManifestAsync(
                Path.Combine(objectDirectory, LockFileFormat.AssetsFileName),
                manifestPath,
                targetFramework,
                runtimeIdentifier,
                ct).ConfigureAwait(false);
        }
        catch (NuGetOperationException ex)
        {
            _logger.LogError("Manifest creation failed");
            _logger.LogError("Manifest creation stderr: {Error}", ex.Output);
            throw new InvalidOperationException($"Manifest creation failed: {ex.Output}", ex);
        }
 
        _logger.LogDebug("Package manifest created at {Path}", manifestPath);
        return manifestPath;
    }
 
    private static bool TryValidatePackageManifest(string manifestPath, ILogger logger)
    {
        try
        {
            _ = IntegrationPackageProbeManifest.Load(manifestPath);
            return true;
        }
        catch (Exception ex) when (ex is InvalidOperationException or JsonException or IOException or UnauthorizedAccessException)
        {
            logger.LogDebug(ex, "Cached package manifest {ManifestPath} is invalid and will be regenerated.", manifestPath);
            return false;
        }
    }
 
    /// <summary>
    /// Gets the file containing the NuGet implementation that performs restores, for the restore cache key.
    /// </summary>
    /// <remarks>
    /// Native AOT compiles the implementation into the executable. A managed launch such as <c>dotnet aspire.dll</c>
    /// runs it from the CLI assembly instead, and <see cref="Environment.ProcessPath"/> is then the <c>dotnet</c> host,
    /// which does not change when the CLI is updated.
    /// </remarks>
    internal static string? GetRestoreToolPath()
    {
        if (!RuntimeFeature.IsDynamicCodeSupported)
        {
            return Environment.ProcessPath;
        }
 
        // Assembly.Location is unavailable to single-file and Native AOT builds, so derive the path from the base
        // directory the managed host loaded the CLI from.
        var assemblyPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(BundleNuGetService).Assembly.GetName().Name}.dll");
        return File.Exists(assemblyPath) ? assemblyPath : Environment.ProcessPath;
    }
 
    internal static string ComputePackageHash(
        List<(string Id, string Version)> packages,
        string tfm,
        string? runtimeIdentifier,
        string? toolPath = null,
        IEnumerable<string>? sources = null)
    {
        // Same inputs and ordering as the helper-era key, so the same restores share a cache entry. In particular,
        // sources are sorted: their order does not change what NuGet restores.
        var content = string.Join(";", packages.OrderBy(package => package.Id).Select(package => $"{package.Id}:{package.Version}"));
        content += $";tfm:{tfm}";
        content += $";rid:{runtimeIdentifier ?? "<none>"}";
        content += $";tool:{GetToolFingerprint(toolPath)}";
        if (sources is not null)
        {
            content += $";sources:{string.Join("|", sources.OrderBy(source => source, StringComparer.OrdinalIgnoreCase))}";
        }
 
        var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(content));
        return hash.ToString("X16", System.Globalization.CultureInfo.InvariantCulture);
    }
 
    private static string GetToolFingerprint(string? toolPath)
    {
        if (string.IsNullOrEmpty(toolPath))
        {
            return "<none>";
        }
 
        try
        {
            var fileInfo = new FileInfo(toolPath);
            return fileInfo.Exists
                ? $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}"
                : "<missing>";
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
        {
            return "<error>";
        }
    }
 
    private static string GetPackageRestoreCacheDirectory(string workingDirectory)
    {
        var integrationCacheDirectory = ConfigurationHelper.GetIntegrationCacheDirectory(
            new DirectoryInfo(Path.GetFullPath(workingDirectory)));
        return Path.Combine(integrationCacheDirectory.FullName, "package-restore");
    }
}