File: Projects\AppHostServerClosureSnapshots.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.Text;
using Aspire.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// Represents an immutable project-reference output layout for the prebuilt AppHost server.
/// </summary>
internal sealed class AppHostServerProjectLayout
{
    public required string LayoutPath { get; init; }
 
    public required string Fingerprint { get; init; }
 
    public string IntegrationLibsPath => Path.Combine(LayoutPath, "libs");
}
 
/// <summary>
/// Represents a resolved file in the prebuilt AppHost server closure.
/// </summary>
/// <param name="SourcePath">The full path of the source file used to materialize the closure.</param>
/// <param name="RelativePath">The file path relative to the integration libs directory.</param>
/// <param name="PackageId">The NuGet package id when the source comes from a restored package.</param>
/// <param name="PackageVersion">The NuGet package version when the source comes from a restored package.</param>
/// <param name="PathInPackage">The relative path inside the NuGet package when the source comes from a restored package.</param>
/// <param name="PackageSha512">The package content hash from <c>project.assets.json</c> when available.</param>
/// <param name="AssetType">The resolved NuGet asset type when the source comes from a restored package.</param>
internal sealed record AppHostServerClosureSource(
    string SourcePath,
    string RelativePath,
    string? PackageId = null,
    string? PackageVersion = null,
    string? PathInPackage = null,
    string? PackageSha512 = null,
    string? AssetType = null);
 
/// <summary>
/// Represents a single file in the prebuilt AppHost server closure manifest.
/// </summary>
internal sealed class AppHostServerClosureManifestEntry
{
    public required string RelativePath { get; init; }
 
    public required string SourcePath { get; init; }
 
    public string? PackageId { get; init; }
 
    public string? PackageVersion { get; init; }
 
    public string? PathInPackage { get; init; }
 
    public string? PackageSha512 { get; init; }
 
    public string? AssetType { get; init; }
 
    public string? FileContentHash { get; init; }
 
    public bool IsPackageBacked =>
        !string.IsNullOrWhiteSpace(PackageId) &&
        !string.IsNullOrWhiteSpace(PackageVersion) &&
        !string.IsNullOrWhiteSpace(PathInPackage) &&
        !string.IsNullOrWhiteSpace(PackageSha512);
}
 
/// <summary>
/// Represents the exact closure used to prepare a prebuilt AppHost server.
/// </summary>
internal sealed class AppHostServerClosureManifest
{
    public required string ManifestFingerprint { get; init; }
 
    public required string? ProjectLayoutFingerprint { get; init; }
 
    public required IReadOnlyList<AppHostServerClosureManifestEntry> Entries { get; init; }
 
    public required string AppSettingsContent { get; init; }
 
    internal IReadOnlyList<string> GetManifestLines()
    {
        var lines = new List<string>(Entries.Count + 1)
        {
            $"content/appsettings.json|{ComputeTextHash(AppSettingsContent)}"
        };
 
        lines.AddRange(Entries.Select(GetEntryFingerprint));
 
        return lines;
    }
 
    internal IReadOnlyList<string> GetProjectLayoutManifestLines()
    {
        return Entries
            .Where(static entry => !entry.IsPackageBacked)
            .Select(GetProjectEntryFingerprint)
            .ToList();
    }
 
    public static AppHostServerClosureManifest Create(
        IEnumerable<AppHostServerClosureSource> sourceFiles,
        string appSettingsContent,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(sourceFiles);
        ArgumentNullException.ThrowIfNull(appSettingsContent);
 
        var entries = new List<AppHostServerClosureManifestEntry>();
        foreach (var sourceFile in sourceFiles)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            var normalizedSourcePath = Path.GetFullPath(sourceFile.SourcePath);
            var normalizedRelativePath = NormalizeRelativePath(sourceFile.RelativePath);
            if (!File.Exists(normalizedSourcePath))
            {
                throw new InvalidOperationException($"Manifest source file '{normalizedSourcePath}' does not exist.");
            }
 
            if (TryCreatePackageBackedEntry(sourceFile, normalizedSourcePath, normalizedRelativePath) is { } packageEntry)
            {
                entries.Add(packageEntry);
                continue;
            }
 
            entries.Add(new AppHostServerClosureManifestEntry
            {
                RelativePath = normalizedRelativePath,
                SourcePath = normalizedSourcePath,
                FileContentHash = ComputeFileHash(normalizedSourcePath, cancellationToken)
            });
        }
 
        entries.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.RelativePath, right.RelativePath));
 
        return new AppHostServerClosureManifest
        {
            ManifestFingerprint = ComputeManifestFingerprint(entries, appSettingsContent),
            ProjectLayoutFingerprint = ComputeProjectLayoutFingerprint(entries),
            Entries = entries,
            AppSettingsContent = appSettingsContent
        };
    }
 
    public IntegrationPackageProbeManifest CreatePackageProbeManifest()
    {
        var managedAssemblies = new List<IntegrationPackageManagedAssembly>();
        var nativeLibraries = new List<IntegrationPackageNativeLibrary>();
 
        foreach (var entry in Entries.Where(static entry => entry.IsPackageBacked))
        {
            if (string.Equals(entry.AssetType, "native", StringComparison.OrdinalIgnoreCase))
            {
                nativeLibraries.Add(new IntegrationPackageNativeLibrary
                {
                    FileName = Path.GetFileName(entry.RelativePath),
                    Path = entry.SourcePath
                });
                continue;
            }
 
            if (!entry.SourcePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }
 
            managedAssemblies.Add(new IntegrationPackageManagedAssembly
            {
                Name = Path.GetFileNameWithoutExtension(entry.RelativePath),
                Culture = TryGetSatelliteCulture(entry),
                Path = entry.SourcePath
            });
        }
 
        return IntegrationPackageProbeManifest.Create(managedAssemblies, nativeLibraries);
    }
 
    private static string ComputeManifestFingerprint(
        IReadOnlyList<AppHostServerClosureManifestEntry> entries,
        string appSettingsContent)
    {
        var values = new List<string>(entries.Count + 1)
        {
            $"content/appsettings.json|{ComputeTextHash(appSettingsContent)}"
        };
 
        values.AddRange(entries.Select(GetEntryFingerprint));
        return ComputeHash(values);
    }
 
    private static string? ComputeProjectLayoutFingerprint(IReadOnlyList<AppHostServerClosureManifestEntry> entries)
    {
        var projectEntries = entries
            .Where(static entry => !entry.IsPackageBacked)
            .Select(GetProjectEntryFingerprint)
            .ToList();
 
        return projectEntries.Count == 0 ? null : ComputeHash(projectEntries);
    }
 
    private static AppHostServerClosureManifestEntry? TryCreatePackageBackedEntry(
        AppHostServerClosureSource sourceFile,
        string normalizedSourcePath,
        string normalizedRelativePath)
    {
        if (string.IsNullOrWhiteSpace(sourceFile.PackageId) ||
            string.IsNullOrWhiteSpace(sourceFile.PackageVersion) ||
            string.IsNullOrWhiteSpace(sourceFile.PathInPackage) ||
            string.IsNullOrWhiteSpace(sourceFile.PackageSha512))
        {
            return null;
        }
 
        return new AppHostServerClosureManifestEntry
        {
            RelativePath = normalizedRelativePath,
            SourcePath = normalizedSourcePath,
            PackageId = sourceFile.PackageId,
            PackageVersion = sourceFile.PackageVersion,
            PathInPackage = sourceFile.PathInPackage,
            PackageSha512 = sourceFile.PackageSha512,
            AssetType = NormalizeAssetType(sourceFile.AssetType)
        };
    }
 
    private static string GetEntryFingerprint(AppHostServerClosureManifestEntry entry)
    {
        if (entry.IsPackageBacked)
        {
            return $"packages/{entry.RelativePath}|{entry.AssetType ?? "runtime"}|{entry.PackageId}|{entry.PackageVersion}|{entry.PathInPackage}|{entry.PackageSha512}|{entry.SourcePath}";
        }
 
        return GetProjectEntryFingerprint(entry);
    }
 
    private static string GetProjectEntryFingerprint(AppHostServerClosureManifestEntry entry)
    {
        if (entry.FileContentHash is { Length: > 0 } fileContentHash)
        {
            return $"libs/{entry.RelativePath}|file|{fileContentHash}";
        }
 
        throw new InvalidOperationException($"Manifest entry '{entry.RelativePath}' does not contain enough information to compute a fingerprint.");
    }
 
    private static string ComputeTextHash(string value)
    {
        var hash = new XxHash3();
        hash.Append(Encoding.UTF8.GetBytes(value));
        return Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant();
    }
 
    internal static string ComputeFileHash(string path, CancellationToken cancellationToken)
    {
        var hash = new XxHash3();
        using var stream = File.OpenRead(path);
        var buffer = new byte[81920];
        int bytesRead;
        while ((bytesRead = stream.Read(buffer)) > 0)
        {
            cancellationToken.ThrowIfCancellationRequested();
            hash.Append(buffer.AsSpan(0, bytesRead));
        }
 
        return Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant();
    }
 
    private static string ComputeHash(IEnumerable<string> values)
    {
        var hash = new XxHash3();
        foreach (var value in values)
        {
            hash.Append(Encoding.UTF8.GetBytes(value));
            hash.Append("\n"u8);
        }
 
        return Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant();
    }
 
    private static string? TryGetSatelliteCulture(AppHostServerClosureManifestEntry entry)
    {
        if (!string.Equals(entry.AssetType, "resources", StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }
 
        var directoryName = Path.GetDirectoryName(entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
        if (string.IsNullOrWhiteSpace(directoryName))
        {
            return null;
        }
 
        return directoryName.Replace('\\', '/').Trim('/');
    }
 
    private static string? NormalizeAssetType(string? assetType)
    {
        return string.IsNullOrWhiteSpace(assetType) ? null : assetType.Trim();
    }
 
    private static string NormalizeRelativePath(string relativePath)
    {
        ArgumentException.ThrowIfNullOrEmpty(relativePath);
 
        return relativePath
            .Replace('\\', '/')
            .TrimStart('/');
    }
}
 
/// <summary>
/// Stores immutable project-reference layouts for the prebuilt AppHost server.
/// </summary>
internal sealed class AppHostServerProjectLayoutStore
{
    private const string LayoutItemsFolderName = "items";
    private const string LayoutManifestFileName = "manifest.txt";
    private const string LayoutRootFolderName = "project-layouts";
    private const string LayoutStagingFolderName = ".staging";
 
    private static readonly TimeSpan s_stagingCleanupAge = TimeSpan.FromHours(1);
 
    private readonly string _itemsDirectory;
    private readonly ILogger _logger;
    private readonly string _stagingDirectory;
 
    public AppHostServerProjectLayoutStore(string rootPath, ILogger logger)
    {
        ArgumentException.ThrowIfNullOrEmpty(rootPath);
 
        var layoutsDirectory = Path.Combine(Path.GetFullPath(rootPath), LayoutRootFolderName);
        _itemsDirectory = Path.Combine(layoutsDirectory, LayoutItemsFolderName);
        _stagingDirectory = Path.Combine(layoutsDirectory, LayoutStagingFolderName);
        _logger = logger;
 
        Directory.CreateDirectory(_itemsDirectory);
        Directory.CreateDirectory(_stagingDirectory);
    }
 
    public void CleanupStagingDirectories()
    {
        if (!Directory.Exists(_stagingDirectory))
        {
            return;
        }
 
        foreach (var stagingDirectory in Directory.EnumerateDirectories(_stagingDirectory))
        {
            try
            {
                var directoryInfo = new DirectoryInfo(stagingDirectory);
                if (DateTime.UtcNow - directoryInfo.LastWriteTimeUtc <= s_stagingCleanupAge)
                {
                    continue;
                }
 
                Directory.Delete(stagingDirectory, recursive: true);
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to clean stale AppHost project layout staging directory {Path}", stagingDirectory);
            }
        }
    }
 
    public AppHostServerProjectLayout? TryLoadLayout(string? fingerprint, AppHostServerClosureManifest? expectedManifest = null)
    {
        if (string.IsNullOrWhiteSpace(fingerprint))
        {
            return null;
        }
 
        var layoutPath = GetLayoutPath(fingerprint);
        var libsPath = Path.Combine(layoutPath, "libs");
        var manifestPath = Path.Combine(layoutPath, LayoutManifestFileName);
 
        if (!Directory.Exists(layoutPath) ||
            !Directory.Exists(libsPath) ||
            !File.Exists(manifestPath))
        {
            return null;
        }
 
        if (expectedManifest is not null &&
            !IsLayoutCurrent(layoutPath, libsPath, manifestPath, expectedManifest))
        {
            return null;
        }
 
        return new AppHostServerProjectLayout
        {
            LayoutPath = layoutPath,
            Fingerprint = fingerprint
        };
    }
 
    public async Task<AppHostServerProjectLayout?> GetOrCreateAsync(
        AppHostServerClosureManifest manifest,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(manifest);
 
        if (manifest.ProjectLayoutFingerprint is null)
        {
            return null;
        }
 
        if (TryLoadLayout(manifest.ProjectLayoutFingerprint, manifest) is { } existingLayout)
        {
            return existingLayout;
        }
 
        DeleteInvalidLayoutDirectory(manifest.ProjectLayoutFingerprint);
 
        var stagingPath = Path.Combine(_stagingDirectory, Guid.NewGuid().ToString("n"));
        var libsPath = Path.Combine(stagingPath, "libs");
 
        Directory.CreateDirectory(libsPath);
 
        try
        {
            foreach (var entry in manifest.Entries.Where(static entry => !entry.IsPackageBacked))
            {
                cancellationToken.ThrowIfCancellationRequested();
 
                var destinationPath = Path.Combine(libsPath, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
                var destinationDirectory = Path.GetDirectoryName(destinationPath);
                if (!string.IsNullOrEmpty(destinationDirectory))
                {
                    Directory.CreateDirectory(destinationDirectory);
                }
 
                File.Copy(entry.SourcePath, destinationPath, overwrite: false);
            }
 
            await WriteManifestFileAsync(Path.Combine(stagingPath, LayoutManifestFileName), manifest, cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            TryDeleteDirectory(stagingPath);
            throw;
        }
 
        var finalLayoutPath = GetLayoutPath(manifest.ProjectLayoutFingerprint);
        try
        {
            Directory.Move(stagingPath, finalLayoutPath);
 
            _logger.LogInformation("Created AppHost project layout {LayoutFingerprint}", manifest.ProjectLayoutFingerprint);
 
            return new AppHostServerProjectLayout
            {
                LayoutPath = finalLayoutPath,
                Fingerprint = manifest.ProjectLayoutFingerprint
            };
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            TryDeleteDirectory(stagingPath);
 
            if (TryLoadLayout(manifest.ProjectLayoutFingerprint, manifest) is { } recoveredLayout)
            {
                return recoveredLayout;
            }
 
            throw;
        }
    }
 
    private string GetLayoutPath(string fingerprint)
    {
        return Path.Combine(_itemsDirectory, fingerprint);
    }
 
    private static bool IsLayoutCurrent(
        string layoutPath,
        string libsPath,
        string manifestPath,
        AppHostServerClosureManifest expectedManifest)
    {
        try
        {
            if (!File.ReadLines(manifestPath).SequenceEqual(expectedManifest.GetProjectLayoutManifestLines(), StringComparer.Ordinal))
            {
                return false;
            }
 
            foreach (var entry in expectedManifest.Entries.Where(static entry => !entry.IsPackageBacked))
            {
                var copiedPath = Path.Combine(libsPath, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
                if (!File.Exists(copiedPath) ||
                    !string.Equals(AppHostServerClosureManifest.ComputeFileHash(copiedPath, CancellationToken.None), entry.FileContentHash, StringComparison.Ordinal))
                {
                    return false;
                }
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            return false;
        }
 
        return Directory.Exists(layoutPath);
    }
 
    private void DeleteInvalidLayoutDirectory(string fingerprint)
    {
        var layoutPath = GetLayoutPath(fingerprint);
        if (!Directory.Exists(layoutPath))
        {
            return;
        }
 
        _logger.LogInformation("Discarding invalid AppHost project layout {LayoutFingerprint}", fingerprint);
        TryDeleteDirectory(layoutPath);
    }
 
    private static async Task WriteManifestFileAsync(
        string path,
        AppHostServerClosureManifest manifest,
        CancellationToken cancellationToken)
    {
        await File.WriteAllLinesAsync(path, manifest.GetProjectLayoutManifestLines(), cancellationToken).ConfigureAwait(false);
    }
 
    private void TryDeleteDirectory(string path)
    {
        if (!Directory.Exists(path))
        {
            return;
        }
 
        try
        {
            Directory.Delete(path, recursive: true);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to delete AppHost project layout directory {Path}", path);
        }
    }
}