File: ApplicationModel\ProjectResource.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
#pragma warning disable ASPIREPROBES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIRECONTAINERRUNTIME001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// A resource that represents a specified .NET project.
/// </summary>
public class ProjectResource : Resource, IResourceWithEnvironment, IResourceWithArgs, IResourceWithServiceDiscovery, IResourceWithWaitSupport, IResourceWithProbes,
    IComputeResource, IContainerFilesDestinationResource
{
    /// <summary>
    /// Initializes a new instance of the <see cref="ProjectResource"/> class.
    /// </summary>
    /// <param name="name">The name of the resource.</param>
    public ProjectResource(string name) : base(name)
    {
        // Add pipeline step annotation to create a build step for this project
        Annotations.Add(new PipelineStepAnnotation((factoryContext) =>
        {
            if (factoryContext.Resource.IsExcludedFromPublish())
            {
                return [];
            }
 
            var buildStep = new PipelineStep
            {
                Name = $"build-{name}",
                Action = BuildProjectImage,
                Tags = [WellKnownPipelineTags.BuildCompute],
                RequiredBySteps = [WellKnownPipelineSteps.Build],
                DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq]
            };
 
            return [buildStep];
        }));
 
        Annotations.Add(new PipelineConfigurationAnnotation(context =>
        {
            // Ensure any static file references' images are built first
            if (this.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
            {
                var buildSteps = context.GetSteps(this, WellKnownPipelineTags.BuildCompute);
 
                foreach (var containerFile in containerFilesAnnotations)
                {
                    buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute));
                }
            }
        }));
    }
    // Keep track of the config host for each Kestrel endpoint annotation
    internal Dictionary<EndpointAnnotation, string> KestrelEndpointAnnotationHosts { get; } = new();
 
    // Are there any endpoints coming from Kestrel configuration
    internal bool HasKestrelEndpoints => KestrelEndpointAnnotationHosts.Count > 0;
 
    // Track the https endpoint that was added as a default, and should be excluded from the port & kestrel environment
    internal EndpointAnnotation? DefaultHttpsEndpoint { get; set; }
 
    internal bool ShouldInjectEndpointEnvironment(EndpointReference e)
    {
        var endpoint = e.EndpointAnnotation;
 
        if (endpoint.UriScheme is not ("http" or "https") ||    // Only process http and https endpoints
            endpoint.TargetPortEnvironmentVariable is not null) // Skip if target port env variable was set
        {
            return false;
        }
 
        // If any filter rejects the endpoint, skip it
        return !Annotations.OfType<EndpointEnvironmentInjectionFilterAnnotation>()
            .Select(a => a.Filter)
            .Any(f => !f(endpoint));
    }
 
    private async Task BuildProjectImage(PipelineStepContext ctx)
    {
        var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
        var logger = ctx.Logger;
 
        // Build the container image for the project first
        await containerImageBuilder.BuildImageAsync(
            this,
            new ContainerBuildOptions
            {
                TargetPlatform = ContainerTargetPlatform.LinuxAmd64
            },
            ctx.CancellationToken).ConfigureAwait(false);
 
        // Check if we need to copy container files
        if (!this.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var _))
        {
            // No container files to copy, just build the image normally
            return;
        }
 
        // Get the built image name
        var originalImageName = Name.ToLowerInvariant();
 
        // Tag the built image with a temporary tag
        var tempTag = $"temp-{Guid.NewGuid():N}";
        var tempImageName = $"{originalImageName}:{tempTag}";
 
        var containerRuntime = ctx.Services.GetRequiredService<IContainerRuntime>();
 
        logger.LogDebug("Tagging image {OriginalImageName} as {TempImageName}", originalImageName, tempImageName);
        await containerRuntime.TagImageAsync(originalImageName, tempImageName, ctx.CancellationToken).ConfigureAwait(false);
 
        // Generate a Dockerfile that layers the container files on top
        var dockerfileBuilder = new DockerfileBuilder();
        dockerfileBuilder.AddContainerFilesStages(this, logger);
 
        var stage = dockerfileBuilder.From(tempImageName);
 
        var projectMetadata = this.GetProjectMetadata();
 
        // Get the container working directory for the project
        var containerWorkingDir = await GetContainerWorkingDirectoryAsync(projectMetadata.ProjectPath, logger, ctx.CancellationToken).ConfigureAwait(false);
 
        // Add COPY --from: statements for each source
        stage.AddContainerFiles(this, containerWorkingDir, logger);
 
        // Write the Dockerfile to a temporary location
        var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!;
        var tempDockerfilePath = Path.GetTempFileName();
 
        var builtSuccessfully = false;
        try
        {
            using (var writer = new StreamWriter(tempDockerfilePath))
            {
                await dockerfileBuilder.WriteAsync(writer, ctx.CancellationToken).ConfigureAwait(false);
            }
 
            logger.LogDebug("Generated temporary Dockerfile at {DockerfilePath}", tempDockerfilePath);
 
            // Build the final image from the generated Dockerfile
            await containerRuntime.BuildImageAsync(
                projectDir,
                tempDockerfilePath,
                originalImageName,
                new ContainerBuildOptions
                {
                    TargetPlatform = ContainerTargetPlatform.LinuxAmd64
                },
                [],
                [],
                null,
                ctx.CancellationToken).ConfigureAwait(false);
 
            logger.LogDebug("Successfully built final image {ImageName} with container files", originalImageName);
            builtSuccessfully = true;
        }
        finally
        {
            if (builtSuccessfully)
            {
                // Clean up the temporary Dockerfile
                if (File.Exists(tempDockerfilePath))
                {
                    try
                    {
                        File.Delete(tempDockerfilePath);
                        logger.LogDebug("Deleted temporary Dockerfile {DockerfilePath}", tempDockerfilePath);
                    }
                    catch (Exception ex)
                    {
                        logger.LogWarning(ex, "Failed to delete temporary Dockerfile {DockerfilePath}", tempDockerfilePath);
                    }
                }
            }
            else
            {
                // Keep the Dockerfile for debugging purposes
                logger.LogDebug("Failed build - temporary Dockerfile left at {DockerfilePath} for debugging", tempDockerfilePath);
            }
 
            // Remove the temporary tagged image
            logger.LogDebug("Removing temporary image {TempImageName}", tempImageName);
            await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false);
        }
    }
 
    private static async Task<string> GetContainerWorkingDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken)
    {
        try
        {
            var outputLines = new List<string>();
            var spec = new Dcp.Process.ProcessSpec("dotnet")
            {
                Arguments = $"msbuild -getProperty:ContainerWorkingDirectory \"{projectPath}\"",
                OnOutputData = output =>
                {
                    if (!string.IsNullOrWhiteSpace(output))
                    {
                        outputLines.Add(output.Trim());
                    }
                },
                OnErrorData = error => logger.LogDebug("dotnet msbuild (stderr): {Error}", error),
                ThrowOnNonZeroReturnCode = false
            };
 
            logger.LogDebug("Getting ContainerWorkingDirectory for project {ProjectPath}", projectPath);
            var (pendingResult, processDisposable) = Dcp.Process.ProcessUtil.Run(spec);
 
            await using (processDisposable.ConfigureAwait(false))
            {
                var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false);
 
                if (result.ExitCode != 0)
                {
                    logger.LogDebug("Failed to get ContainerWorkingDirectory from dotnet msbuild for project {ProjectPath}. Exit code: {ExitCode}. Using default /app",
                        projectPath, result.ExitCode);
                    return "/app";
                }
 
                // The last non-empty line should contain the ContainerWorkingDirectory value
                var workingDir = outputLines.LastOrDefault();
 
                if (string.IsNullOrWhiteSpace(workingDir))
                {
                    logger.LogDebug("dotnet msbuild returned empty ContainerWorkingDirectory for project {ProjectPath}. Using default /app", projectPath);
                    return "/app";
                }
 
                logger.LogDebug("Resolved ContainerWorkingDirectory for project {ProjectPath}: {WorkingDir}", projectPath, workingDir);
                return workingDir;
            }
        }
        catch (Exception ex)
        {
            logger.LogDebug(ex, "Error getting ContainerWorkingDirectory. Using default /app");
            return "/app";
        }
    }
}