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.Text.Json;
using Aspire.Cli.Bundles;
using Aspire.Cli.Configuration;
using Aspire.Cli.Layout;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.NuGet;
 
/// <summary>
/// Service for NuGet operations that works in bundle mode.
/// Uses the NuGetHelper tool via the layout runtime.
/// </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>
/// NuGet service implementation that uses the bundle's NuGetHelper tool.
/// </summary>
internal sealed class BundleNuGetService : INuGetService
{
    private readonly ILayoutDiscovery _layoutDiscovery;
    private readonly LayoutProcessRunner _layoutProcessRunner;
    private readonly IFeatures _features;
    private readonly IEnvironment _environment;
    private readonly ILogger<BundleNuGetService> _logger;
    private readonly IBundleService? _bundleService;
 
    public BundleNuGetService(
        ILayoutDiscovery layoutDiscovery,
        LayoutProcessRunner layoutProcessRunner,
        IFeatures features,
        IEnvironment environment,
        ILogger<BundleNuGetService> logger,
        IBundleService? bundleService = null)
    {
        _layoutDiscovery = layoutDiscovery;
        _layoutProcessRunner = layoutProcessRunner;
        _features = features;
        _environment = environment;
        _logger = logger;
        _bundleService = bundleService;
    }
 
    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);
 
        using var layoutLease = _bundleService is null
            ? null
            : await _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "nuget-restore", ct).ConfigureAwait(false);
        var layout = layoutLease?.Layout ?? _layoutDiscovery.DiscoverLayout();
        if (layout is null)
        {
            throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet restore in bundle mode.");
        }
 
        var managedPath = layout.GetManagedPath();
        if (managedPath is null || !File.Exists(managedPath))
        {
            throw new InvalidOperationException("aspire-managed not found in layout.");
        }
 
        var packageList = packages.ToList();
        if (packageList.Count == 0)
        {
            throw new ArgumentException("At least one package is required", nameof(packages));
        }
 
        // Compute a hash for the package set to create a unique restore location.
        var packageHash = ComputePackageHash(packageList, targetFramework, runtimeIdentifier, managedPath, sources);
        var restoreCacheDirectory = GetPackageRestoreCacheDirectory(workingDirectory);
        var restoreDir = Path.Combine(restoreCacheDirectory, packageHash);
        var objDir = Path.Combine(restoreDir, "obj");
        var manifestPath = Path.Combine(restoreDir, IntegrationPackageProbeManifest.FileName);
        var assetsPath = Path.Combine(objDir, "project.assets.json");
        var lockPath = Path.Combine(restoreDir, "restore.lock");
 
        // The package cache is shared by every AppHost in the workspace. Serialize the
        // restore and manifest write so one process cannot start RemoteHost while another
        // process is rewriting the same manifest or project.assets.json file.
        using var fileLock = await FileLock.AcquireAsync(lockPath, ct).ConfigureAwait(false);
 
        // Check if already restored after acquiring the lock because another process may
        // have populated the shared cache while this process was waiting.
        if (File.Exists(manifestPath) && TryValidatePackageManifest(manifestPath, _logger))
        {
            _logger.LogDebug("Using cached package manifest at {Path}", manifestPath);
            return manifestPath;
        }
 
        Directory.CreateDirectory(objDir);
 
        // Step 1: Restore packages
        // Prepend "nuget" subcommand for aspire-managed dispatch
        var restoreArgs = new List<string>
        {
            "nuget",
            "restore",
            "--output", objDir,
            "--framework", targetFramework
        };
 
        if (!string.IsNullOrEmpty(runtimeIdentifier))
        {
            restoreArgs.Add("--runtime-identifier");
            restoreArgs.Add(runtimeIdentifier);
        }
 
        foreach (var (id, version) in packageList)
        {
            restoreArgs.Add("--package");
            restoreArgs.Add($"{id},{version}");
        }
 
        if (sources is not null)
        {
            foreach (var source in sources)
            {
                restoreArgs.Add("--source");
                restoreArgs.Add(source);
            }
        }
 
        // Pass working directory for nuget.config discovery.
        restoreArgs.Add("--working-dir");
        restoreArgs.Add(workingDirectory);
 
        if (!string.IsNullOrEmpty(nugetConfigPath))
        {
            restoreArgs.Add("--nuget-config");
            restoreArgs.Add(nugetConfigPath);
        }
 
        // Enable verbose output for debugging
        if (_logger.IsEnabled(LogLevel.Debug))
        {
            restoreArgs.Add("--verbose");
        }
 
        _logger.LogDebug("Restoring {Count} packages", packageList.Count);
        _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath);
        if (_logger.IsEnabled(LogLevel.Debug))
        {
            // Build a redacted copy of the args specifically for the log line so user-supplied
            // credentialed feeds (e.g., `https://user:pat@host/v3/index.json`, SAS-token URLs) do
            // not flow to the debug log alongside the rest of the restore invocation. The
            // original `restoreArgs` list is still passed verbatim to the process below.
            _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", BuildRedactedArgsForLog(restoreArgs)));
        }
 
        var environmentVariables = new Dictionary<string, string>();
        NuGetSignatureVerificationEnabler.Apply(environmentVariables, _features, _environment);
        layoutLease?.AddEnvironment(environmentVariables);
 
        var (exitCode, output, error) = await _layoutProcessRunner.RunAsync(
            managedPath,
            restoreArgs,
            environmentVariables: environmentVariables,
            // A restore against a slow/unresponsive NuGet source can hang. LayoutProcessRunner uses this
            // to bind the helper to the CLI's Windows kill-on-close job (and, on non-Windows, to instead
            // arm the cooperative parent-liveness watchdog) so a hard-killed CLI cannot leak it.
            killOnParentExit: true,
            ct: ct);
 
        // Log stderr at debug level for diagnostics
        if (!string.IsNullOrWhiteSpace(error))
        {
            _logger.LogDebug("NuGetHelper restore stderr: {Error}", error);
        }
 
        if (exitCode != 0)
        {
            _logger.LogError("Package restore failed with exit code {ExitCode}", exitCode);
            _logger.LogError("Package restore stderr: {Error}", error);
            _logger.LogError("Package restore stdout: {Output}", output);
            throw new InvalidOperationException($"Package restore failed: {error}");
        }
 
        // Step 2: Create package probe manifest
        // Prepend "nuget" subcommand for aspire-managed dispatch
        var manifestArgs = new List<string>
        {
            "nuget",
            "manifest",
            "--assets", assetsPath,
            "--output", manifestPath,
            "--framework", targetFramework
        };
 
        if (!string.IsNullOrEmpty(runtimeIdentifier))
        {
            manifestArgs.Add("--runtime-identifier");
            manifestArgs.Add(runtimeIdentifier);
        }
 
        // Enable verbose output for debugging
        if (_logger.IsEnabled(LogLevel.Debug))
        {
            manifestArgs.Add("--verbose");
        }
 
        _logger.LogDebug("Creating package manifest from {AssetsPath}", assetsPath);
        _logger.LogDebug("NuGet manifest args: {Args}", string.Join(" ", manifestArgs));
 
        (exitCode, output, error) = await _layoutProcessRunner.RunAsync(
            managedPath,
            manifestArgs,
            environmentVariables: environmentVariables,
            // Same rationale as the restore step above: keep this aspire-managed helper from outliving a
            // hard-killed CLI (Windows kill-on-close job, or the cooperative watchdog on other hosts).
            killOnParentExit: true,
            ct: ct);
 
        // Log stderr at debug level for diagnostics
        if (!string.IsNullOrWhiteSpace(error))
        {
            _logger.LogDebug("NuGetHelper manifest stderr: {Error}", error);
        }
 
        if (exitCode != 0)
        {
            _logger.LogError("Manifest creation failed with exit code {ExitCode}", exitCode);
            _logger.LogError("Manifest creation stderr: {Error}", error);
            _logger.LogError("Manifest creation stdout: {Output}", output);
            throw new InvalidOperationException($"Manifest creation failed: {error}");
        }
 
        _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;
        }
    }
 
    // Returns a redacted copy of the restore args suitable for debug logging. Replaces the value
    // immediately following each `--source` token with the credential-safe form from
    // PackageSourceRedactor. Built defensively to handle repeated `--source` flags and a missing
    // trailing value at the end of the args list.
    private static IReadOnlyList<string> BuildRedactedArgsForLog(IReadOnlyList<string> args)
    {
        var redacted = new List<string>(args.Count);
        for (var i = 0; i < args.Count; i++)
        {
            redacted.Add(args[i]);
            if (string.Equals(args[i], "--source", StringComparison.Ordinal) && i + 1 < args.Count)
            {
                redacted.Add(PackageSourceRedactor.RedactForDisplay(args[++i]));
            }
        }
 
        return redacted;
    }
 
    internal static string ComputePackageHash(
        List<(string Id, string Version)> packages,
        string tfm,
        string? runtimeIdentifier,
        string? managedPath = null,
        IEnumerable<string>? sources = null)
    {
        var content = string.Join(";", packages.OrderBy(p => p.Id).Select(p => $"{p.Id}:{p.Version}"));
        content += $";tfm:{tfm}";
        content += $";rid:{runtimeIdentifier ?? "<none>"}";
        content += $";managed:{GetManagedToolFingerprint(managedPath)}";
        if (sources is not null)
        {
            content += $";sources:{string.Join("|", sources.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))}";
        }
 
        // Use SHA256 for stable hash across processes/runtimes
        var hashBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content));
        return Convert.ToHexString(hashBytes)[..16]; // Use first 16 chars (64 bits) for reasonable uniqueness
    }
 
    private static string GetManagedToolFingerprint(string? managedPath)
    {
        if (string.IsNullOrEmpty(managedPath))
        {
            return "<none>";
        }
 
        try
        {
            var fileInfo = new FileInfo(managedPath);
            if (!fileInfo.Exists)
            {
                return "<missing>";
            }
 
            return $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}";
        }
        catch (IOException)
        {
            return "<error>";
        }
        catch (UnauthorizedAccessException)
        {
            return "<error>";
        }
        catch (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");
    }
}