File: DotnetProjectBuildResource.cs
Web Access
Project: src\src\Aspire.Hosting.Dotnet\Aspire.Hosting.Dotnet.csproj (Aspire.Hosting.Dotnet)
// 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 System.Xml;
using System.Xml.Linq;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Dotnet;
 
/// <summary>
/// Builds .NET projects and file-based apps in a distributed application before they start.
/// </summary>
internal sealed class DotnetProjectBuildResource : ExecutableResource, IDisposable
{
    private readonly object _lock = new();
    private readonly List<string> _projectPaths = [];
    private readonly Dictionary<string, string> _projectPathsByIdentity = new(StringComparer.Ordinal);
    private readonly DotnetProjectBuildArtifactManager _artifactManager;
    private bool _buildProjectGenerationStarted;
    private string? _buildConfiguration;
    private string? _directProjectPath;
 
    internal DotnetProjectBuildResource(
        string name,
        string workingDirectory,
        string buildDirectory,
        TimeProvider timeProvider)
        : base(name, "dotnet", workingDirectory)
    {
        BuildDirectory = buildDirectory;
        _artifactManager = new DotnetProjectBuildArtifactManager(BuildDirectory, timeProvider);
    }
 
    /// <summary>
    /// Gets the AppHost intermediate-output directory that contains generated build projects.
    /// </summary>
    public string BuildDirectory { get; }
 
    /// <summary>
    /// Gets the project paths included in the generated build project.
    /// </summary>
    public IReadOnlyList<string> ProjectPaths
    {
        get
        {
            lock (_lock)
            {
                return [.. _projectPaths];
            }
        }
    }
 
    internal string? BuildConfiguration
    {
        get
        {
            lock (_lock)
            {
                return _buildConfiguration;
            }
        }
    }
 
    /// <summary>
    /// Adds a project to the generated build project and returns the path used by the coordinated build.
    /// </summary>
    public string AddProject(string projectPath)
    {
        var fullPath = Path.GetFullPath(projectPath);
        // Keep the first path spelling in the traversal project so it stays relative to the AppHost directory,
        // but deduplicate by physical identity so a symlink alias cannot build the same project twice.
        // Every resource for that identity must launch this returned path so MSBuild uses the same
        // project directory, intermediate outputs, and final output that the coordinated build used.
        var projectIdentity = PathNormalizer.ResolveToFilesystemPath(fullPath);
 
        lock (_lock)
        {
            if (_buildProjectGenerationStarted)
            {
                throw new InvalidOperationException("Projects cannot be added after the coordinated build project has been generated.");
            }
 
            if (_projectPathsByIdentity.TryGetValue(projectIdentity, out var coordinatedProjectPath))
            {
                return coordinatedProjectPath;
            }
 
            _projectPathsByIdentity.Add(projectIdentity, fullPath);
            _projectPaths.Add(fullPath);
            return fullPath;
        }
    }
 
    internal void ConfigureTraversalBuild(
        IEnumerable<string> projectPaths,
        string workingDirectory,
        string? buildConfiguration)
    {
        ArgumentNullException.ThrowIfNull(projectPaths);
        ArgumentException.ThrowIfNullOrEmpty(workingDirectory);
 
        lock (_lock)
        {
            ThrowIfGenerationStarted();
            _buildConfiguration = buildConfiguration;
            _directProjectPath = null;
            _projectPaths.Clear();
            _projectPathsByIdentity.Clear();
            foreach (var projectPath in projectPaths)
            {
                AddProject(projectPath);
            }
 
            SetWorkingDirectory(workingDirectory);
        }
    }
 
    internal void ConfigureDirectBuild(
        string projectPath,
        string workingDirectory,
        string? buildConfiguration)
    {
        ArgumentException.ThrowIfNullOrEmpty(projectPath);
        ArgumentException.ThrowIfNullOrEmpty(workingDirectory);
 
        lock (_lock)
        {
            ThrowIfGenerationStarted();
            _buildConfiguration = buildConfiguration;
            _projectPaths.Clear();
            _projectPathsByIdentity.Clear();
            _directProjectPath = AddProject(projectPath);
            SetWorkingDirectory(workingDirectory);
        }
    }
 
    internal void SetBuildConfiguration(string? buildConfiguration)
    {
        lock (_lock)
        {
            ThrowIfGenerationStarted();
            _buildConfiguration = buildConfiguration;
        }
    }
 
    internal Task<string> GetBuildTargetPathAsync(ILogger logger, CancellationToken cancellationToken)
    {
        lock (_lock)
        {
            if (_directProjectPath is not null)
            {
                _buildProjectGenerationStarted = true;
                return Task.FromResult(_directProjectPath);
            }
        }
 
        return WriteBuildProjectAsync(logger, cancellationToken);
    }
 
    /// <summary>
    /// Writes the generated traversal project to the AppHost intermediate-output build directory.
    /// </summary>
    public Task<string> WriteBuildProjectAsync(ILogger logger, CancellationToken cancellationToken)
    {
        IReadOnlyList<string> projectPaths;
        lock (_lock)
        {
            _buildProjectGenerationStarted = true;
            projectPaths = [.. _projectPaths];
        }
 
        // The argument callback already caches successful evaluation for one start attempt. Regenerate here
        // on later attempts so cancellation, transient I/O failures, or cache cleanup cannot poison restarts.
        return WriteBuildProjectCoreAsync(projectPaths, logger, cancellationToken);
    }
 
    /// <summary>
    /// Registers process-lifetime cleanup for generated build-project leases.
    /// </summary>
    public void RegisterForShutdown(IHostApplicationLifetime applicationLifetime)
    {
        _artifactManager.RegisterForShutdown(applicationLifetime);
    }
 
    internal bool IsBuildProjectLeaseActive(string hash) =>
        _artifactManager.IsLeaseActive(hash);
 
    /// <inheritdoc/>
    public void Dispose()
    {
        _artifactManager.Dispose();
    }
 
    private async Task<string> WriteBuildProjectCoreAsync(
        IReadOnlyList<string> projectPaths,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var project = new XDocument(
            new XElement(
                "Project",
                new XAttribute("DefaultTargets", "Build"),
                new XElement(
                    "ItemGroup",
                    projectPaths.Select(projectPath =>
                        new XElement(
                            "ProjectFile",
                            new XAttribute(
                                "Include",
                                EscapeMsBuildPath(NormalizePath(Path.GetRelativePath(BuildDirectory, projectPath))))))),
                CreateTraversalTarget("Restore", buildInParallel: false),
                CreateTraversalTarget("Build", buildInParallel: true)));
 
        using var projectStream = new MemoryStream();
        var writerSettings = new XmlWriterSettings
        {
            Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
            Indent = true,
            OmitXmlDeclaration = true,
        };
        using (var writer = XmlWriter.Create(projectStream, writerSettings))
        {
            project.Save(writer);
        }
        var projectBytes = projectStream.ToArray();
 
        var hash = new XxHash3();
        hash.Append(projectBytes);
        var hashString = Convert.ToHexString(hash.GetCurrentHash())[..12].ToLowerInvariant();
 
        return await _artifactManager.PublishAndLeaseAsync(
            hashString,
            projectBytes,
            logger,
            cancellationToken).ConfigureAwait(false);
    }
 
    private static XElement CreateTraversalTarget(string name, bool buildInParallel) =>
        new(
            "Target",
            new XAttribute("Name", name),
            new XElement(
                "MSBuild",
                new XAttribute("Projects", "@(ProjectFile)"),
                new XAttribute("Targets", name),
                new XAttribute("BuildInParallel", buildInParallel)));
 
    private static string NormalizePath(string path) =>
        path.Replace(Path.DirectorySeparatorChar, '/');
 
    private static string EscapeMsBuildPath(string path)
    {
        var builder = new StringBuilder(path.Length);
        foreach (var character in path)
        {
            builder.Append(character switch
            {
                '%' => "%25",
                '$' => "%24",
                '@' => "%40",
                '(' => "%28",
                ')' => "%29",
                '*' => "%2A",
                '\'' => "%27",
                ';' => "%3B",
                '?' => "%3F",
                _ => character,
            });
        }
 
        return builder.ToString();
    }
 
    private void SetWorkingDirectory(string workingDirectory)
    {
        var executableAnnotation = Annotations.OfType<ExecutableAnnotation>().Single();
        executableAnnotation.WorkingDirectory = workingDirectory;
    }
 
    private void ThrowIfGenerationStarted()
    {
        if (_buildProjectGenerationStarted)
        {
            throw new InvalidOperationException("The coordinated build cannot be reconfigured after build-project generation starts.");
        }
    }
}