From cf46c78ed233916d8ca87397612949d17cbea037 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 22:05:14 +0000 Subject: [PATCH 01/17] Initial plan From 2a8cabb5034f6e596e2ca08b8966429a4a0730dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Oct 2025 22:27:08 +0000 Subject: [PATCH 02/17] Add ContainerFilesDestinationAnnotation support to ProjectResource Implement pipeline step factory for ProjectResource to support copying static files from other container resources before building docker images. Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ProjectResourceBuilderExtensions.cs | 185 ++++++++++++++++++ .../ProjectResourceTests.cs | 39 ++++ 2 files changed, 224 insertions(+) diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 0dcdbe5b112..76ca3a6abc2 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -2,11 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREEXTENSION001 +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPUBLISHERS001 using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Dcp.Process; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -707,6 +712,28 @@ EndpointAnnotation GetOrCreateEndpointForScheme(string scheme) httpEndpoint.TargetPort = httpsEndpoint.TargetPort = defaultEndpointTargetPort; } + // Add pipeline step factory to handle ContainerFilesDestinationAnnotation + builder.WithPipelineStepFactory(factoryContext => + { + List steps = []; + var buildStep = CreateProjectBuildImageStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource); + steps.Add(buildStep); + + // Ensure any static file references' images are built first + if (factoryContext.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + foreach (var containerFile in containerFilesAnnotations) + { + var source = containerFile.Source; + var staticFileBuildStep = CreateProjectBuildImageStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source); + buildStep.DependsOn(staticFileBuildStep); + steps.Add(staticFileBuildStep); + } + } + + return steps; + }); + return builder; } @@ -1042,4 +1069,162 @@ private sealed class ProjectContainerResource(ProjectResource pr) : ContainerRes { public override ResourceAnnotationCollection Annotations => pr.Annotations; } + + private static PipelineStep CreateProjectBuildImageStep(string stepName, IResource resource) => + new() + { + Name = stepName, + Action = async ctx => + { + // Copy files from source containers if ContainerFilesDestinationAnnotation is present + if (resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + await CopyContainerFilesToProjectAsync(resource, containerFilesAnnotations, ctx.Services, ctx.CancellationToken).ConfigureAwait(false); + } + + // Build the container image for the project + var containerImageBuilder = ctx.Services.GetRequiredService(); + await containerImageBuilder.BuildImageAsync( + resource, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + ctx.CancellationToken).ConfigureAwait(false); + }, + Tags = [WellKnownPipelineTags.BuildCompute] + }; + + private static async Task CopyContainerFilesToProjectAsync( + IResource resource, + IEnumerable containerFilesAnnotations, + IServiceProvider services, + CancellationToken cancellationToken) + { + var logger = services.GetRequiredService().CreateLogger(typeof(ProjectResourceBuilderExtensions)); + var projectMetadata = resource.TryGetLastAnnotation(out var metadata) ? metadata : null; + + if (projectMetadata == null) + { + logger.LogWarning("Project metadata not found for resource {ResourceName}. Cannot copy container files.", resource.Name); + return; + } + + var projectPath = projectMetadata.ProjectPath; + var publishDir = Path.Combine(Path.GetDirectoryName(projectPath)!, "bin", "Release", "publish"); + + // Ensure the publish directory exists + Directory.CreateDirectory(publishDir); + + foreach (var containerFileDestination in containerFilesAnnotations) + { + var source = containerFileDestination.Source; + + // Get the image name from the source resource + if (!source.TryGetContainerImageName(out var imageName)) + { + logger.LogWarning("Cannot copy container files from {SourceName}: Source resource does not have a container image name.", source.Name); + continue; + } + + logger.LogInformation("Copying container files from {ImageName} to {PublishDir}", imageName, publishDir); + + // For each ContainerFilesSourceAnnotation on the source resource, copy the files + foreach (var containerFilesSource in source.Annotations.OfType()) + { + var sourcePath = containerFilesSource.SourcePath; + var destinationPath = containerFileDestination.DestinationPath; + + // If destination path is relative, make it relative to the publish directory + if (!Path.IsPathRooted(destinationPath)) + { + destinationPath = Path.Combine(publishDir, destinationPath); + } + + // Ensure the destination directory exists + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + try + { + // Create a temporary container from the image + var containerName = $"temp-{resource.Name}-{Guid.NewGuid():N}"; + + logger.LogDebug("Creating temporary container {ContainerName} from image {ImageName}", containerName, imageName); + var createSpec = new ProcessSpec("docker") + { + Arguments = $"create --name {containerName} {imageName}", + OnOutputData = output => logger.LogDebug("docker create output: {Output}", output), + OnErrorData = error => logger.LogDebug("docker create error: {Error}", error) + }; + + var (pendingCreateResult, createDisposable) = ProcessUtil.Run(createSpec); + await using (createDisposable.ConfigureAwait(false)) + { + var createResult = await pendingCreateResult.WaitAsync(cancellationToken).ConfigureAwait(false); + if (createResult.ExitCode != 0) + { + logger.LogError("Failed to create temporary container {ContainerName} from image {ImageName}. Exit code: {ExitCode}", + containerName, imageName, createResult.ExitCode); + continue; + } + } + + try + { + // Copy files from the container + logger.LogDebug("Copying files from {ContainerName}:{SourcePath} to {DestinationPath}", containerName, sourcePath, destinationPath); + var copySpec = new ProcessSpec("docker") + { + Arguments = $"cp {containerName}:{sourcePath} {destinationPath}", + OnOutputData = output => logger.LogDebug("docker cp output: {Output}", output), + OnErrorData = error => logger.LogDebug("docker cp error: {Error}", error) + }; + + var (pendingCopyResult, copyDisposable) = ProcessUtil.Run(copySpec); + await using (copyDisposable.ConfigureAwait(false)) + { + var copyResult = await pendingCopyResult.WaitAsync(cancellationToken).ConfigureAwait(false); + if (copyResult.ExitCode != 0) + { + logger.LogError("Failed to copy files from container {ContainerName}:{SourcePath} to {DestinationPath}. Exit code: {ExitCode}", + containerName, sourcePath, destinationPath, copyResult.ExitCode); + } + else + { + logger.LogInformation("Successfully copied files from {ImageName}:{SourcePath} to {DestinationPath}", + imageName, sourcePath, destinationPath); + } + } + } + finally + { + // Clean up the temporary container + logger.LogDebug("Removing temporary container {ContainerName}", containerName); + var rmSpec = new ProcessSpec("docker") + { + Arguments = $"rm {containerName}", + OnOutputData = output => logger.LogDebug("docker rm output: {Output}", output), + OnErrorData = error => logger.LogDebug("docker rm error: {Error}", error) + }; + + var (pendingRmResult, rmDisposable) = ProcessUtil.Run(rmSpec); + await using (rmDisposable.ConfigureAwait(false)) + { + var rmResult = await pendingRmResult.WaitAsync(cancellationToken).ConfigureAwait(false); + if (rmResult.ExitCode != 0) + { + logger.LogWarning("Failed to remove temporary container {ContainerName}. Exit code: {ExitCode}", + containerName, rmResult.ExitCode); + } + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error copying files from {ImageName}:{SourcePath} to {DestinationPath}", + imageName, sourcePath, destinationPath); + } + } + } + } } diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index ba326394673..89e079441b1 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPIPELINES001 + using System.Text; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Tests.Helpers; using Aspire.Hosting.Tests.Utils; @@ -893,4 +896,40 @@ public TestProjectWithExecutableProfile() }; } } + + [Fact] + public void ProjectResourceWithContainerFilesDestinationAnnotationCreatesPipelineSteps() + { + var appBuilder = CreateBuilder(); + + // Create a test container resource that implements IResourceWithContainerFiles + var sourceContainerResource = new TestContainerFilesResource("source"); + var sourceContainer = appBuilder.AddResource(sourceContainerResource) + .WithImage("myimage") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); + + // Add a project and annotate it with ContainerFilesDestinationAnnotation + appBuilder.AddProject("projectName", launchProfileName: null) + .PublishWithContainerFiles(sourceContainer, "./static"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var projectResources = appModel.GetProjectResources(); + + var resource = Assert.Single(projectResources); + + // Verify the ContainerFilesDestinationAnnotation was added + var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(sourceContainer.Resource, containerFilesAnnotation.Source); + Assert.Equal("./static", containerFilesAnnotation.DestinationPath); + + // Verify the PipelineStepAnnotation was added by WithProjectDefaults + var pipelineStepAnnotations = resource.Annotations.OfType().ToList(); + Assert.NotEmpty(pipelineStepAnnotations); + } + + private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles + { + } } From e342d5dfe489b227bcf1889593f56575879e4e73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 03:16:44 +0000 Subject: [PATCH 03/17] Address PR feedback: use IContainerRuntime, fix PublishDir, generic source build steps - Added CopyContainerFilesAsync method to IContainerRuntime interface with implementations in ContainerRuntimeBase - Changed CopyContainerFilesToProjectAsync to use IContainerRuntime instead of calling docker directly - Fixed GetPublishDirectoryAsync to use 'dotnet msbuild -c Release -getProperty:PublishDir' instead of hardcoded path - Changed source resource build step to use generic CreateBuildImageStep instead of CreateProjectBuildImageStep Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ProjectResourceBuilderExtensions.cs | 169 ++++++++++-------- .../Publishing/ContainerRuntimeBase.cs | 54 ++++++ .../Publishing/IContainerRuntime.cs | 1 + 3 files changed, 153 insertions(+), 71 deletions(-) diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 76ca3a6abc2..7a6d9a6ae7d 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -8,6 +8,7 @@ using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; +using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Pipelines; @@ -19,6 +20,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aspire.Hosting; @@ -725,7 +727,7 @@ EndpointAnnotation GetOrCreateEndpointForScheme(string scheme) foreach (var containerFile in containerFilesAnnotations) { var source = containerFile.Source; - var staticFileBuildStep = CreateProjectBuildImageStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source); + var staticFileBuildStep = CreateBuildImageStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source); buildStep.DependsOn(staticFileBuildStep); steps.Add(staticFileBuildStep); } @@ -1070,6 +1072,24 @@ private sealed class ProjectContainerResource(ProjectResource pr) : ContainerRes public override ResourceAnnotationCollection Annotations => pr.Annotations; } + private static PipelineStep CreateBuildImageStep(string stepName, IResource resource) => + new() + { + Name = stepName, + Action = async ctx => + { + var containerImageBuilder = ctx.Services.GetRequiredService(); + await containerImageBuilder.BuildImageAsync( + resource, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + ctx.CancellationToken).ConfigureAwait(false); + }, + Tags = [WellKnownPipelineTags.BuildCompute] + }; + private static PipelineStep CreateProjectBuildImageStep(string stepName, IResource resource) => new() { @@ -1111,11 +1131,27 @@ private static async Task CopyContainerFilesToProjectAsync( } var projectPath = projectMetadata.ProjectPath; - var publishDir = Path.Combine(Path.GetDirectoryName(projectPath)!, "bin", "Release", "publish"); + + // Get the PublishDir using dotnet msbuild + var publishDir = await GetPublishDirectoryAsync(projectPath, logger, cancellationToken).ConfigureAwait(false); + + if (publishDir == null) + { + logger.LogWarning("Could not determine publish directory for project {ProjectPath}. Cannot copy container files.", projectPath); + return; + } // Ensure the publish directory exists Directory.CreateDirectory(publishDir); + // Get the container runtime + var dcpOptions = services.GetRequiredService>(); + var containerRuntime = dcpOptions.Value.ContainerRuntime switch + { + string rt => services.GetRequiredKeyedService(rt), + null => services.GetRequiredKeyedService("docker") + }; + foreach (var containerFileDestination in containerFilesAnnotations) { var source = containerFileDestination.Source; @@ -1146,85 +1182,76 @@ private static async Task CopyContainerFilesToProjectAsync( try { - // Create a temporary container from the image - var containerName = $"temp-{resource.Name}-{Guid.NewGuid():N}"; - - logger.LogDebug("Creating temporary container {ContainerName} from image {ImageName}", containerName, imageName); - var createSpec = new ProcessSpec("docker") - { - Arguments = $"create --name {containerName} {imageName}", - OnOutputData = output => logger.LogDebug("docker create output: {Output}", output), - OnErrorData = error => logger.LogDebug("docker create error: {Error}", error) - }; + await containerRuntime.CopyContainerFilesAsync(imageName, sourcePath, destinationPath, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Successfully copied files from {ImageName}:{SourcePath} to {DestinationPath}", + imageName, sourcePath, destinationPath); + } + catch (Exception ex) + { + logger.LogError(ex, "Error copying files from {ImageName}:{SourcePath} to {DestinationPath}", + imageName, sourcePath, destinationPath); + } + } + } + } - var (pendingCreateResult, createDisposable) = ProcessUtil.Run(createSpec); - await using (createDisposable.ConfigureAwait(false)) + private static async Task GetPublishDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken) + { + try + { + var outputLines = new List(); + var spec = new ProcessSpec("dotnet") + { + Arguments = $"msbuild -c Release -getProperty:PublishDir \"{projectPath}\"", + OnOutputData = output => + { + if (!string.IsNullOrWhiteSpace(output)) { - var createResult = await pendingCreateResult.WaitAsync(cancellationToken).ConfigureAwait(false); - if (createResult.ExitCode != 0) - { - logger.LogError("Failed to create temporary container {ContainerName} from image {ImageName}. Exit code: {ExitCode}", - containerName, imageName, createResult.ExitCode); - continue; - } + outputLines.Add(output.Trim()); } + }, + OnErrorData = error => logger.LogDebug("dotnet msbuild (stderr): {Error}", error), + ThrowOnNonZeroReturnCode = false + }; - try - { - // Copy files from the container - logger.LogDebug("Copying files from {ContainerName}:{SourcePath} to {DestinationPath}", containerName, sourcePath, destinationPath); - var copySpec = new ProcessSpec("docker") - { - Arguments = $"cp {containerName}:{sourcePath} {destinationPath}", - OnOutputData = output => logger.LogDebug("docker cp output: {Output}", output), - OnErrorData = error => logger.LogDebug("docker cp error: {Error}", error) - }; + logger.LogDebug("Running dotnet msbuild to get PublishDir for project {ProjectPath}", projectPath); + var (pendingResult, processDisposable) = ProcessUtil.Run(spec); - var (pendingCopyResult, copyDisposable) = ProcessUtil.Run(copySpec); - await using (copyDisposable.ConfigureAwait(false)) - { - var copyResult = await pendingCopyResult.WaitAsync(cancellationToken).ConfigureAwait(false); - if (copyResult.ExitCode != 0) - { - logger.LogError("Failed to copy files from container {ContainerName}:{SourcePath} to {DestinationPath}. Exit code: {ExitCode}", - containerName, sourcePath, destinationPath, copyResult.ExitCode); - } - else - { - logger.LogInformation("Successfully copied files from {ImageName}:{SourcePath} to {DestinationPath}", - imageName, sourcePath, destinationPath); - } - } - } - finally - { - // Clean up the temporary container - logger.LogDebug("Removing temporary container {ContainerName}", containerName); - var rmSpec = new ProcessSpec("docker") - { - Arguments = $"rm {containerName}", - OnOutputData = output => logger.LogDebug("docker rm output: {Output}", output), - OnErrorData = error => logger.LogDebug("docker rm error: {Error}", error) - }; + await using (processDisposable.ConfigureAwait(false)) + { + var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + logger.LogWarning("Failed to get PublishDir from dotnet msbuild for project {ProjectPath}. Exit code: {ExitCode}", + projectPath, result.ExitCode); + return null; + } - var (pendingRmResult, rmDisposable) = ProcessUtil.Run(rmSpec); - await using (rmDisposable.ConfigureAwait(false)) - { - var rmResult = await pendingRmResult.WaitAsync(cancellationToken).ConfigureAwait(false); - if (rmResult.ExitCode != 0) - { - logger.LogWarning("Failed to remove temporary container {ContainerName}. Exit code: {ExitCode}", - containerName, rmResult.ExitCode); - } - } - } + // The last non-empty line should contain the PublishDir value + var publishDir = outputLines.LastOrDefault(); + + if (string.IsNullOrWhiteSpace(publishDir)) + { + logger.LogWarning("dotnet msbuild returned empty PublishDir for project {ProjectPath}", projectPath); + return null; } - catch (Exception ex) + + // Make it an absolute path if it's relative + if (!Path.IsPathRooted(publishDir)) { - logger.LogError(ex, "Error copying files from {ImageName}:{SourcePath} to {DestinationPath}", - imageName, sourcePath, destinationPath); + var projectDir = Path.GetDirectoryName(projectPath); + publishDir = Path.GetFullPath(Path.Combine(projectDir!, publishDir)); } + + logger.LogDebug("Resolved PublishDir for project {ProjectPath}: {PublishDir}", projectPath, publishDir); + return publishDir; } } + catch (Exception ex) + { + logger.LogError(ex, "Error getting PublishDir for project {ProjectPath}", projectPath); + return null; + } } } diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 540a7ff4789..0359b6f2fbb 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -63,6 +63,60 @@ await ExecuteContainerCommandAsync( imageName).ConfigureAwait(false); } + public virtual async Task CopyContainerFilesAsync(string imageName, string sourcePath, string destinationPath, CancellationToken cancellationToken) + { + var containerName = $"temp-{Guid.NewGuid():N}"; + + try + { + // Create a temporary container from the image + _logger.LogDebug("Creating temporary container {ContainerName} from image {ImageName}", containerName, imageName); + var createArguments = $"create --name {containerName} {imageName}"; + + var createExitCode = await ExecuteContainerCommandWithExitCodeAsync( + createArguments, + $"{Name} create for {{ContainerName}} from {{ImageName}} failed with exit code {{ExitCode}}.", + $"{Name} create for {{ContainerName}} from {{ImageName}} succeeded.", + cancellationToken, + new object[] { containerName, imageName }).ConfigureAwait(false); + + if (createExitCode != 0) + { + throw new DistributedApplicationException($"{Name} create failed with exit code {createExitCode}."); + } + + // Copy files from the container + _logger.LogDebug("Copying files from {ContainerName}:{SourcePath} to {DestinationPath}", containerName, sourcePath, destinationPath); + var copyArguments = $"cp {containerName}:{sourcePath} {destinationPath}"; + + await ExecuteContainerCommandAsync( + copyArguments, + $"{Name} cp from {{ContainerName}}:{{SourcePath}} to {{DestinationPath}} failed with exit code {{ExitCode}}.", + $"{Name} cp from {{ContainerName}}:{{SourcePath}} to {{DestinationPath}} succeeded.", + $"{Name} cp failed with exit code {{0}}.", + cancellationToken, + containerName, sourcePath, destinationPath).ConfigureAwait(false); + } + finally + { + // Clean up the temporary container + _logger.LogDebug("Removing temporary container {ContainerName}", containerName); + var rmArguments = $"rm {containerName}"; + + var rmExitCode = await ExecuteContainerCommandWithExitCodeAsync( + rmArguments, + $"{Name} rm for {{ContainerName}} failed with exit code {{ExitCode}}.", + $"{Name} rm for {{ContainerName}} succeeded.", + cancellationToken, + new object[] { containerName }).ConfigureAwait(false); + + if (rmExitCode != 0) + { + _logger.LogWarning("{RuntimeName} rm for {ContainerName} failed with exit code {ExitCode}.", Name, containerName, rmExitCode); + } + } + } + /// /// Executes a container runtime command with standard logging and error handling. /// diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index f1eb3ec2edc..5510c73009d 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -12,4 +12,5 @@ internal interface IContainerRuntime Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken); Task PushImageAsync(string imageName, CancellationToken cancellationToken); + Task CopyContainerFilesAsync(string imageName, string sourcePath, string destinationPath, CancellationToken cancellationToken); } From 6719d444231e6cec7de7ae9a58fb6749ccfa1bfb Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 27 Oct 2025 14:43:36 -0500 Subject: [PATCH 04/17] Make it work in the playground --- .../AspireJavaScript.AppHost/AppHost.cs | 31 +++---------------- .../AspireJavaScript.AppHost.csproj | 1 + .../AspireJavaScript.MinimalApi/AppHost.cs | 5 ++- .../AspireJavaScript.Vite/vite.config.ts | 1 - .../ProjectResourceBuilderExtensions.cs | 2 +- 5 files changed, 11 insertions(+), 29 deletions(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index 15c89c54b9e..70c89817666 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -1,37 +1,16 @@ var builder = DistributedApplication.CreateBuilder(args); +builder.AddAzureContainerAppEnvironment("env"); + var weatherApi = builder.AddProject("weatherapi") .WithExternalHttpEndpoints(); -builder.AddNpmApp("angular", "../AspireJavaScript.Angular") - .WithNpmPackageManager() - .WithReference(weatherApi) - .WaitFor(weatherApi) - .WithHttpEndpoint(env: "PORT") - .WithExternalHttpEndpoints() - .PublishAsDockerFile(); - -builder.AddNpmApp("react", "../AspireJavaScript.React") - .WithNpmPackageManager() - .WithReference(weatherApi) - .WaitFor(weatherApi) - .WithEnvironment("BROWSER", "none") // Disable opening browser on npm start - .WithHttpEndpoint(env: "PORT") - .WithExternalHttpEndpoints() - .PublishAsDockerFile(); - -builder.AddNpmApp("vue", "../AspireJavaScript.Vue") - .WithNpmPackageManager() - .WithReference(weatherApi) - .WaitFor(weatherApi) - .WithHttpEndpoint(env: "PORT") - .WithExternalHttpEndpoints() - .PublishAsDockerFile(); - -builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") +var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") .WithNpmPackageManager() .WithReference(weatherApi) .WithEnvironment("BROWSER", "none") .WithExternalHttpEndpoints(); +weatherApi.PublishWithContainerFiles(reactvite, "./wwwroot"); + builder.Build().Run(); diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj index 262a263f6d4..5b5f0fd490e 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj @@ -12,6 +12,7 @@ + diff --git a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs index cafd6bbc0cb..5dd7c6a5fe9 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs @@ -30,7 +30,7 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; -app.MapGet("/weatherforecast", () => +app.MapGet("/api/weatherforecast", () => { var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast @@ -45,6 +45,9 @@ .WithName("GetWeatherForecast") .WithOpenApi(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + app.Run(); sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vite/vite.config.ts b/playground/AspireWithJavaScript/AspireJavaScript.Vite/vite.config.ts index 7bfe8f7b5b6..2659772c06c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Vite/vite.config.ts +++ b/playground/AspireWithJavaScript/AspireJavaScript.Vite/vite.config.ts @@ -13,7 +13,6 @@ export default defineConfig(({ mode }) => { '/api': { target: process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP, changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), secure: false, } } diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 7aa185ef67b..4a7d43fcf84 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -1202,7 +1202,7 @@ private static async Task CopyContainerFilesToProjectAsync( var outputLines = new List(); var spec = new ProcessSpec("dotnet") { - Arguments = $"msbuild -c Release -getProperty:PublishDir \"{projectPath}\"", + Arguments = $"msbuild -p:Configuration=Release -getProperty:PublishDir \"{projectPath}\"", OnOutputData = output => { if (!string.IsNullOrWhiteSpace(output)) From cd9a23f47a32fec2bebbd76920fd2aedee5caf1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:47:52 +0000 Subject: [PATCH 05/17] Refactor to build project image first, then layer container files via Dockerfile - Build ProjectResource image with normal flow first - Tag built image with temporary GUID tag - Generate Dockerfile that FROMs temp image and COPY --from source containers - Build final image from generated Dockerfile with real tag - Removed CopyContainerFilesAsync from IContainerRuntime (no longer needed) - Removed GetPublishDirectoryAsync helper (no longer needed) Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ProjectResourceBuilderExtensions.cs | 247 ++++++++---------- 1 file changed, 110 insertions(+), 137 deletions(-) diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 4a7d43fcf84..0fc0dac07db 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -4,13 +4,14 @@ #pragma warning disable ASPIREEXTENSION001 #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIREDOCKERFILEBUILDER001 using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; -using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; @@ -1096,14 +1097,26 @@ private static PipelineStep CreateProjectBuildImageStep(string stepName, IResour Name = stepName, Action = async ctx => { - // Copy files from source containers if ContainerFilesDestinationAnnotation is present - if (resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + var containerImageBuilder = ctx.Services.GetRequiredService(); + var logger = ctx.Services.GetRequiredService().CreateLogger(typeof(ProjectResourceBuilderExtensions)); + + // Check if we need to copy container files + var hasContainerFiles = resource.TryGetAnnotationsOfType(out var containerFilesAnnotations); + + if (!hasContainerFiles) { - await CopyContainerFilesToProjectAsync(resource, containerFilesAnnotations, ctx.Services, ctx.CancellationToken).ConfigureAwait(false); + // No container files to copy, just build the image normally + await containerImageBuilder.BuildImageAsync( + resource, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + ctx.CancellationToken).ConfigureAwait(false); + return; } - - // Build the container image for the project - var containerImageBuilder = ctx.Services.GetRequiredService(); + + // Build the container image for the project first await containerImageBuilder.BuildImageAsync( resource, new ContainerBuildOptions @@ -1111,147 +1124,107 @@ await containerImageBuilder.BuildImageAsync( TargetPlatform = ContainerTargetPlatform.LinuxAmd64 }, ctx.CancellationToken).ConfigureAwait(false); - }, - Tags = [WellKnownPipelineTags.BuildCompute] - }; - - private static async Task CopyContainerFilesToProjectAsync( - IResource resource, - IEnumerable containerFilesAnnotations, - IServiceProvider services, - CancellationToken cancellationToken) - { - var logger = services.GetRequiredService().CreateLogger(typeof(ProjectResourceBuilderExtensions)); - var projectMetadata = resource.TryGetLastAnnotation(out var metadata) ? metadata : null; - - if (projectMetadata == null) - { - logger.LogWarning("Project metadata not found for resource {ResourceName}. Cannot copy container files.", resource.Name); - return; - } - - var projectPath = projectMetadata.ProjectPath; - - // Get the PublishDir using dotnet msbuild - var publishDir = await GetPublishDirectoryAsync(projectPath, logger, cancellationToken).ConfigureAwait(false); - - if (publishDir == null) - { - logger.LogWarning("Could not determine publish directory for project {ProjectPath}. Cannot copy container files.", projectPath); - return; - } - - // Ensure the publish directory exists - Directory.CreateDirectory(publishDir); - - // Get the container runtime - var dcpOptions = services.GetRequiredService>(); - var containerRuntime = dcpOptions.Value.ContainerRuntime switch - { - string rt => services.GetRequiredKeyedService(rt), - null => services.GetRequiredKeyedService("docker") - }; - - foreach (var containerFileDestination in containerFilesAnnotations) - { - var source = containerFileDestination.Source; - - // Get the image name from the source resource - if (!source.TryGetContainerImageName(out var imageName)) - { - logger.LogWarning("Cannot copy container files from {SourceName}: Source resource does not have a container image name.", source.Name); - continue; - } - - logger.LogInformation("Copying container files from {ImageName} to {PublishDir}", imageName, publishDir); - - // For each ContainerFilesSourceAnnotation on the source resource, copy the files - foreach (var containerFilesSource in source.Annotations.OfType()) - { - var sourcePath = containerFilesSource.SourcePath; - var destinationPath = containerFileDestination.DestinationPath; - - // If destination path is relative, make it relative to the publish directory - if (!Path.IsPathRooted(destinationPath)) - { - destinationPath = Path.Combine(publishDir, destinationPath); - } - - // Ensure the destination directory exists - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - - try + + // Get the built image name + if (!resource.TryGetContainerImageName(out var originalImageName)) { - await containerRuntime.CopyContainerFilesAsync(imageName, sourcePath, destinationPath, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Successfully copied files from {ImageName}:{SourcePath} to {DestinationPath}", - imageName, sourcePath, destinationPath); + logger.LogError("Cannot get container image name for resource {ResourceName}", resource.Name); + throw new InvalidOperationException($"Cannot get container image name for resource {resource.Name}"); } - catch (Exception ex) + + // Tag the built image with a temporary tag + var tempTag = $"temp-{Guid.NewGuid():N}"; + var tempImageName = $"{originalImageName.Split(':')[0]}:{tempTag}"; + + var dcpOptions = ctx.Services.GetRequiredService>(); + var containerRuntime = dcpOptions.Value.ContainerRuntime switch { - logger.LogError(ex, "Error copying files from {ImageName}:{SourcePath} to {DestinationPath}", - imageName, sourcePath, destinationPath); - } - } - } - } - - private static async Task GetPublishDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken) - { - try - { - var outputLines = new List(); - var spec = new ProcessSpec("dotnet") - { - Arguments = $"msbuild -p:Configuration=Release -getProperty:PublishDir \"{projectPath}\"", - OnOutputData = output => + string rt => ctx.Services.GetRequiredKeyedService(rt), + null => ctx.Services.GetRequiredKeyedService("docker") + }; + + logger.LogInformation("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(); + var stage = dockerfileBuilder.From(tempImageName); + + // Add COPY --from: statements for each source + foreach (var containerFileDestination in containerFilesAnnotations!) { - if (!string.IsNullOrWhiteSpace(output)) + var source = containerFileDestination.Source; + + if (!source.TryGetContainerImageName(out var sourceImageName)) { - outputLines.Add(output.Trim()); + logger.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name); + continue; + } + + var destinationPath = containerFileDestination.DestinationPath; + if (!destinationPath.StartsWith('/')) + { + // Make it an absolute path relative to /app (typical .NET container working directory) + destinationPath = $"/app/{destinationPath}"; + } + + foreach (var containerFilesSource in source.Annotations.OfType()) + { + logger.LogInformation("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}", + sourceImageName, containerFilesSource.SourcePath, destinationPath); + stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath); } - }, - OnErrorData = error => logger.LogDebug("dotnet msbuild (stderr): {Error}", error), - ThrowOnNonZeroReturnCode = false - }; - - logger.LogDebug("Running dotnet msbuild to get PublishDir for project {ProjectPath}", projectPath); - var (pendingResult, processDisposable) = ProcessUtil.Run(spec); - - await using (processDisposable.ConfigureAwait(false)) - { - var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false); - - if (result.ExitCode != 0) - { - logger.LogWarning("Failed to get PublishDir from dotnet msbuild for project {ProjectPath}. Exit code: {ExitCode}", - projectPath, result.ExitCode); - return null; } - - // The last non-empty line should contain the PublishDir value - var publishDir = outputLines.LastOrDefault(); - if (string.IsNullOrWhiteSpace(publishDir)) + // Write the Dockerfile to a temporary location + var projectResource = (ProjectResource)resource; + var projectMetadata = projectResource.GetProjectMetadata(); + var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; + var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}"); + + try { - logger.LogWarning("dotnet msbuild returned empty PublishDir for project {ProjectPath}", projectPath); - return null; + 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 + }, + new Dictionary(), + new Dictionary(), + null, + ctx.CancellationToken).ConfigureAwait(false); + + logger.LogInformation("Successfully built final image {ImageName} with container files", originalImageName); } - - // Make it an absolute path if it's relative - if (!Path.IsPathRooted(publishDir)) + finally { - var projectDir = Path.GetDirectoryName(projectPath); - publishDir = Path.GetFullPath(Path.Combine(projectDir!, publishDir)); + // 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); + } + } } + }, + Tags = [WellKnownPipelineTags.BuildCompute] + }; - logger.LogDebug("Resolved PublishDir for project {ProjectPath}: {PublishDir}", projectPath, publishDir); - return publishDir; - } - } - catch (Exception ex) - { - logger.LogError(ex, "Error getting PublishDir for project {ProjectPath}", projectPath); - return null; - } - } } From 1b0aa02c9c7934714b1a29023e6f85c6b043560f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:49:12 +0000 Subject: [PATCH 06/17] Revert CopyContainerFilesAsync from IContainerRuntime - no longer needed The new design uses DockerfileBuilder with COPY --from: instead of extracting files from containers to the host filesystem, so the CopyContainerFilesAsync method is no longer needed. Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../Publishing/ContainerRuntimeBase.cs | 54 ------------------- .../Publishing/IContainerRuntime.cs | 1 - 2 files changed, 55 deletions(-) diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 0359b6f2fbb..540a7ff4789 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -63,60 +63,6 @@ await ExecuteContainerCommandAsync( imageName).ConfigureAwait(false); } - public virtual async Task CopyContainerFilesAsync(string imageName, string sourcePath, string destinationPath, CancellationToken cancellationToken) - { - var containerName = $"temp-{Guid.NewGuid():N}"; - - try - { - // Create a temporary container from the image - _logger.LogDebug("Creating temporary container {ContainerName} from image {ImageName}", containerName, imageName); - var createArguments = $"create --name {containerName} {imageName}"; - - var createExitCode = await ExecuteContainerCommandWithExitCodeAsync( - createArguments, - $"{Name} create for {{ContainerName}} from {{ImageName}} failed with exit code {{ExitCode}}.", - $"{Name} create for {{ContainerName}} from {{ImageName}} succeeded.", - cancellationToken, - new object[] { containerName, imageName }).ConfigureAwait(false); - - if (createExitCode != 0) - { - throw new DistributedApplicationException($"{Name} create failed with exit code {createExitCode}."); - } - - // Copy files from the container - _logger.LogDebug("Copying files from {ContainerName}:{SourcePath} to {DestinationPath}", containerName, sourcePath, destinationPath); - var copyArguments = $"cp {containerName}:{sourcePath} {destinationPath}"; - - await ExecuteContainerCommandAsync( - copyArguments, - $"{Name} cp from {{ContainerName}}:{{SourcePath}} to {{DestinationPath}} failed with exit code {{ExitCode}}.", - $"{Name} cp from {{ContainerName}}:{{SourcePath}} to {{DestinationPath}} succeeded.", - $"{Name} cp failed with exit code {{0}}.", - cancellationToken, - containerName, sourcePath, destinationPath).ConfigureAwait(false); - } - finally - { - // Clean up the temporary container - _logger.LogDebug("Removing temporary container {ContainerName}", containerName); - var rmArguments = $"rm {containerName}"; - - var rmExitCode = await ExecuteContainerCommandWithExitCodeAsync( - rmArguments, - $"{Name} rm for {{ContainerName}} failed with exit code {{ExitCode}}.", - $"{Name} rm for {{ContainerName}} succeeded.", - cancellationToken, - new object[] { containerName }).ConfigureAwait(false); - - if (rmExitCode != 0) - { - _logger.LogWarning("{RuntimeName} rm for {ContainerName} failed with exit code {ExitCode}.", Name, containerName, rmExitCode); - } - } - } - /// /// Executes a container runtime command with standard logging and error handling. /// diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 5510c73009d..f1eb3ec2edc 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -12,5 +12,4 @@ internal interface IContainerRuntime Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken); Task PushImageAsync(string imageName, CancellationToken cancellationToken); - Task CopyContainerFilesAsync(string imageName, string sourcePath, string destinationPath, CancellationToken cancellationToken); } From 1272212ea0be597065483d734eb02f2ff38629b9 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 27 Oct 2025 22:01:26 -0500 Subject: [PATCH 07/17] Make it work by using the correct image name. --- .../ProjectResourceBuilderExtensions.cs | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 0fc0dac07db..96e2632d940 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -1099,10 +1099,10 @@ private static PipelineStep CreateProjectBuildImageStep(string stepName, IResour { var containerImageBuilder = ctx.Services.GetRequiredService(); var logger = ctx.Services.GetRequiredService().CreateLogger(typeof(ProjectResourceBuilderExtensions)); - + // Check if we need to copy container files var hasContainerFiles = resource.TryGetAnnotationsOfType(out var containerFilesAnnotations); - + if (!hasContainerFiles) { // No container files to copy, just build the image normally @@ -1115,7 +1115,7 @@ await containerImageBuilder.BuildImageAsync( ctx.CancellationToken).ConfigureAwait(false); return; } - + // Build the container image for the project first await containerImageBuilder.BuildImageAsync( resource, @@ -1124,50 +1124,46 @@ await containerImageBuilder.BuildImageAsync( TargetPlatform = ContainerTargetPlatform.LinuxAmd64 }, ctx.CancellationToken).ConfigureAwait(false); - + // Get the built image name - if (!resource.TryGetContainerImageName(out var originalImageName)) - { - logger.LogError("Cannot get container image name for resource {ResourceName}", resource.Name); - throw new InvalidOperationException($"Cannot get container image name for resource {resource.Name}"); - } - + var originalImageName = resource.Name.ToLowerInvariant(); + // Tag the built image with a temporary tag var tempTag = $"temp-{Guid.NewGuid():N}"; - var tempImageName = $"{originalImageName.Split(':')[0]}:{tempTag}"; - + var tempImageName = $"{originalImageName}:{tempTag}"; + var dcpOptions = ctx.Services.GetRequiredService>(); var containerRuntime = dcpOptions.Value.ContainerRuntime switch { string rt => ctx.Services.GetRequiredKeyedService(rt), null => ctx.Services.GetRequiredKeyedService("docker") }; - + logger.LogInformation("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(); var stage = dockerfileBuilder.From(tempImageName); - + // Add COPY --from: statements for each source foreach (var containerFileDestination in containerFilesAnnotations!) { var source = containerFileDestination.Source; - + if (!source.TryGetContainerImageName(out var sourceImageName)) { logger.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name); continue; } - + var destinationPath = containerFileDestination.DestinationPath; if (!destinationPath.StartsWith('/')) { // Make it an absolute path relative to /app (typical .NET container working directory) destinationPath = $"/app/{destinationPath}"; } - + foreach (var containerFilesSource in source.Annotations.OfType()) { logger.LogInformation("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}", @@ -1175,22 +1171,22 @@ await containerImageBuilder.BuildImageAsync( stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath); } } - + // Write the Dockerfile to a temporary location var projectResource = (ProjectResource)resource; var projectMetadata = projectResource.GetProjectMetadata(); var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}"); - + 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, @@ -1204,7 +1200,7 @@ await containerRuntime.BuildImageAsync( new Dictionary(), null, ctx.CancellationToken).ConfigureAwait(false); - + logger.LogInformation("Successfully built final image {ImageName} with container files", originalImageName); } finally From 0e65f1f346fc9b12edc516b184fb7dd4ab0cacc6 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 14:33:10 -0500 Subject: [PATCH 08/17] Update for latest code --- .../AspireJavaScript.MinimalApi/AppHost.cs | 3 +- .../ApplicationModel/ProjectResource.cs | 152 +++++++++++++-- .../ProjectResourceBuilderExtensions.cs | 178 ------------------ 3 files changed, 142 insertions(+), 191 deletions(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs index 5dd7c6a5fe9..784b93baeab 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs @@ -45,8 +45,7 @@ .WithName("GetWeatherForecast") .WithOpenApi(); -app.UseDefaultFiles(); -app.UseStaticFiles(); +app.UseFileServer(); app.Run(); diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 5c17385a850..00526f862c9 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -1,13 +1,18 @@ #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. // 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.Dcp; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.ApplicationModel; @@ -34,17 +39,7 @@ public ProjectResource(string name) : base(name) var buildStep = new PipelineStep { Name = $"build-{name}", - Action = async ctx => - { - var containerImageBuilder = ctx.Services.GetRequiredService(); - await containerImageBuilder.BuildImageAsync( - this, - new ContainerBuildOptions - { - TargetPlatform = ContainerTargetPlatform.LinuxAmd64 - }, - ctx.CancellationToken).ConfigureAwait(false); - }, + Action = BuildProjectImage, Tags = [WellKnownPipelineTags.BuildCompute], RequiredBySteps = [WellKnownPipelineSteps.Build], DependsOnSteps = [WellKnownPipelineSteps.BuildPrereq] @@ -52,6 +47,20 @@ await containerImageBuilder.BuildImageAsync( return [buildStep]; })); + + Annotations.Add(new PipelineConfigurationAnnotation(context => + { + // Ensure any static file references' images are built first + if (this.TryGetAnnotationsOfType(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 KestrelEndpointAnnotationHosts { get; } = new(); @@ -77,4 +86,125 @@ internal bool ShouldInjectEndpointEnvironment(EndpointReference e) .Select(a => a.Filter) .Any(f => !f(endpoint)); } + + private async Task BuildProjectImage(PipelineStepContext ctx) + { + var containerImageBuilder = ctx.Services.GetRequiredService(); + var logger = ctx.Services.GetRequiredService>(); + + // 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 + var hasContainerFiles = this.TryGetAnnotationsOfType(out var containerFilesAnnotations); + if (!hasContainerFiles) + { + // 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 dcpOptions = ctx.Services.GetRequiredService>(); + var containerRuntime = dcpOptions.Value.ContainerRuntime switch + { + string rt => ctx.Services.GetRequiredKeyedService(rt), + null => ctx.Services.GetRequiredKeyedService("docker") + }; + + 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(); + var stage = dockerfileBuilder.From(tempImageName); + + // Add COPY --from: statements for each source + foreach (var containerFileDestination in containerFilesAnnotations!) + { + var source = containerFileDestination.Source; + + if (!source.TryGetContainerImageName(out var sourceImageName)) + { + logger.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name); + continue; + } + + var destinationPath = containerFileDestination.DestinationPath; + if (!destinationPath.StartsWith('/')) + { + // Make it an absolute path relative to /app (typical .NET container working directory) + destinationPath = $"/app/{destinationPath}"; + } + + foreach (var containerFilesSource in source.Annotations.OfType()) + { + logger.LogDebug("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}", + sourceImageName, containerFilesSource.SourcePath, destinationPath); + stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath); + } + } + + // Write the Dockerfile to a temporary location + var projectMetadata = this.GetProjectMetadata(); + var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; + var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}"); + + 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); + } + finally + { + // 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); + } + } + + // Remove the temporary tagged image + //logger.LogDebug("Removing temporary image {TempImageName}", tempImageName); + //await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index ae6bb87f317..5d552d4fa57 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -8,12 +8,8 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ApplicationModel.Docker; using Aspire.Hosting.Dashboard; -using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; -using Aspire.Hosting.Pipelines; -using Aspire.Hosting.Publishing; using Aspire.Hosting.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -21,7 +17,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Aspire.Hosting; @@ -709,28 +704,6 @@ EndpointAnnotation GetOrCreateEndpointForScheme(string scheme) httpEndpoint.TargetPort = httpsEndpoint.TargetPort = defaultEndpointTargetPort; } - // Add pipeline step factory to handle ContainerFilesDestinationAnnotation - builder.WithPipelineStepFactory(factoryContext => - { - List steps = []; - var buildStep = CreateProjectBuildImageStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource); - steps.Add(buildStep); - - // Ensure any static file references' images are built first - if (factoryContext.Resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) - { - foreach (var containerFile in containerFilesAnnotations) - { - var source = containerFile.Source; - var staticFileBuildStep = CreateBuildImageStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source); - buildStep.DependsOn(staticFileBuildStep); - steps.Add(staticFileBuildStep); - } - } - - return steps; - }); - return builder; } @@ -1048,155 +1021,4 @@ private sealed class ProjectContainerResource(ProjectResource pr) : ContainerRes { public override ResourceAnnotationCollection Annotations => pr.Annotations; } - - private static PipelineStep CreateBuildImageStep(string stepName, IResource resource) => - new() - { - Name = stepName, - Action = async ctx => - { - var containerImageBuilder = ctx.Services.GetRequiredService(); - await containerImageBuilder.BuildImageAsync( - resource, - new ContainerBuildOptions - { - TargetPlatform = ContainerTargetPlatform.LinuxAmd64 - }, - ctx.CancellationToken).ConfigureAwait(false); - }, - Tags = [WellKnownPipelineTags.BuildCompute] - }; - - private static PipelineStep CreateProjectBuildImageStep(string stepName, IResource resource) => - new() - { - Name = stepName, - Action = async ctx => - { - var containerImageBuilder = ctx.Services.GetRequiredService(); - var logger = ctx.Services.GetRequiredService().CreateLogger(typeof(ProjectResourceBuilderExtensions)); - - // Check if we need to copy container files - var hasContainerFiles = resource.TryGetAnnotationsOfType(out var containerFilesAnnotations); - - if (!hasContainerFiles) - { - // No container files to copy, just build the image normally - await containerImageBuilder.BuildImageAsync( - resource, - new ContainerBuildOptions - { - TargetPlatform = ContainerTargetPlatform.LinuxAmd64 - }, - ctx.CancellationToken).ConfigureAwait(false); - return; - } - - // Build the container image for the project first - await containerImageBuilder.BuildImageAsync( - resource, - new ContainerBuildOptions - { - TargetPlatform = ContainerTargetPlatform.LinuxAmd64 - }, - ctx.CancellationToken).ConfigureAwait(false); - - // Get the built image name - var originalImageName = resource.Name.ToLowerInvariant(); - - // Tag the built image with a temporary tag - var tempTag = $"temp-{Guid.NewGuid():N}"; - var tempImageName = $"{originalImageName}:{tempTag}"; - - var dcpOptions = ctx.Services.GetRequiredService>(); - var containerRuntime = dcpOptions.Value.ContainerRuntime switch - { - string rt => ctx.Services.GetRequiredKeyedService(rt), - null => ctx.Services.GetRequiredKeyedService("docker") - }; - - logger.LogInformation("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(); - var stage = dockerfileBuilder.From(tempImageName); - - // Add COPY --from: statements for each source - foreach (var containerFileDestination in containerFilesAnnotations!) - { - var source = containerFileDestination.Source; - - if (!source.TryGetContainerImageName(out var sourceImageName)) - { - logger.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name); - continue; - } - - var destinationPath = containerFileDestination.DestinationPath; - if (!destinationPath.StartsWith('/')) - { - // Make it an absolute path relative to /app (typical .NET container working directory) - destinationPath = $"/app/{destinationPath}"; - } - - foreach (var containerFilesSource in source.Annotations.OfType()) - { - logger.LogInformation("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}", - sourceImageName, containerFilesSource.SourcePath, destinationPath); - stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath); - } - } - - // Write the Dockerfile to a temporary location - var projectResource = (ProjectResource)resource; - var projectMetadata = projectResource.GetProjectMetadata(); - var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; - var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}"); - - 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 - }, - new Dictionary(), - new Dictionary(), - null, - ctx.CancellationToken).ConfigureAwait(false); - - logger.LogInformation("Successfully built final image {ImageName} with container files", originalImageName); - } - finally - { - // 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); - } - } - } - }, - Tags = [WellKnownPipelineTags.BuildCompute] - }; - } From ba83528469ff17e98ec5490799fb985338b79de3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:51:26 +0000 Subject: [PATCH 09/17] Add RemoveImageAsync to IContainerRuntime for cleanup of temporary images - Added RemoveImageAsync method to IContainerRuntime interface - Implemented in ContainerRuntimeBase using 'rmi' command - Uncommented cleanup code in ProjectResource to remove temporary tagged images - Ensures temporary images are cleaned up after layering container files Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ApplicationModel/ProjectResource.cs | 4 ++-- .../Publishing/ContainerRuntimeBase.cs | 13 +++++++++++++ src/Aspire.Hosting/Publishing/IContainerRuntime.cs | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 00526f862c9..ffb0f4dadef 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -203,8 +203,8 @@ await containerRuntime.BuildImageAsync( } // Remove the temporary tagged image - //logger.LogDebug("Removing temporary image {TempImageName}", tempImageName); - //await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false); + logger.LogDebug("Removing temporary image {TempImageName}", tempImageName); + await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false); } } } diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 3c2df71da7a..5c740abd8c8 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -63,6 +63,19 @@ await ExecuteContainerCommandAsync( imageName).ConfigureAwait(false); } + public virtual async Task RemoveImageAsync(string imageName, CancellationToken cancellationToken) + { + var arguments = $"rmi \"{imageName}\""; + + await ExecuteContainerCommandAsync( + arguments, + $"{Name} rmi for {{ImageName}} failed with exit code {{ExitCode}}.", + $"{Name} rmi for {{ImageName}} succeeded.", + $"{Name} rmi failed with exit code {{0}}.", + cancellationToken, + imageName).ConfigureAwait(false); + } + /// /// Executes a container runtime command with standard logging and error handling. /// diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 8858f556aff..a445e6df42e 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -12,4 +12,5 @@ internal interface IContainerRuntime Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken); Task PushImageAsync(string imageName, CancellationToken cancellationToken); + Task RemoveImageAsync(string imageName, CancellationToken cancellationToken); } From c81b36c664c13b82266be158f62eabe810dd1502 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 14:57:51 -0500 Subject: [PATCH 10/17] move test --- .../ProjectResourceTests.cs | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 8eef46a7ab0..7cd1c3cce14 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -767,6 +767,38 @@ public async Task ProjectResource_AutomaticallyGeneratesBuildStep_WithCorrectDep Assert.Contains(WellKnownPipelineSteps.BuildPrereq, buildStep.DependsOnSteps); } + [Fact] + public void ProjectResourceWithContainerFilesDestinationAnnotationCreatesPipelineSteps() + { + var appBuilder = CreateBuilder(); + + // Create a test container resource that implements IResourceWithContainerFiles + var sourceContainerResource = new TestContainerFilesResource("source"); + var sourceContainer = appBuilder.AddResource(sourceContainerResource) + .WithImage("myimage") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); + + // Add a project and annotate it with ContainerFilesDestinationAnnotation + appBuilder.AddProject("projectName", launchProfileName: null) + .PublishWithContainerFiles(sourceContainer, "./wwwroot"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var projectResources = appModel.GetProjectResources(); + + var resource = Assert.Single(projectResources); + + // Verify the ContainerFilesDestinationAnnotation was added + var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(sourceContainer.Resource, containerFilesAnnotation.Source); + Assert.Equal("./static", containerFilesAnnotation.DestinationPath); + + // Verify the PipelineStepAnnotation was added by WithProjectDefaults + var pipelineStepAnnotations = resource.Annotations.OfType().ToList(); + Assert.NotEmpty(pipelineStepAnnotations); + } + internal static IDistributedApplicationBuilder CreateBuilder(string[]? args = null, DistributedApplicationOperation operation = DistributedApplicationOperation.Publish) { var resolvedArgs = new List(); @@ -932,38 +964,6 @@ public TestProjectWithExecutableProfile() } } - [Fact] - public void ProjectResourceWithContainerFilesDestinationAnnotationCreatesPipelineSteps() - { - var appBuilder = CreateBuilder(); - - // Create a test container resource that implements IResourceWithContainerFiles - var sourceContainerResource = new TestContainerFilesResource("source"); - var sourceContainer = appBuilder.AddResource(sourceContainerResource) - .WithImage("myimage") - .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); - - // Add a project and annotate it with ContainerFilesDestinationAnnotation - appBuilder.AddProject("projectName", launchProfileName: null) - .PublishWithContainerFiles(sourceContainer, "./static"); - - using var app = appBuilder.Build(); - - var appModel = app.Services.GetRequiredService(); - var projectResources = appModel.GetProjectResources(); - - var resource = Assert.Single(projectResources); - - // Verify the ContainerFilesDestinationAnnotation was added - var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType()); - Assert.Equal(sourceContainer.Resource, containerFilesAnnotation.Source); - Assert.Equal("./static", containerFilesAnnotation.DestinationPath); - - // Verify the PipelineStepAnnotation was added by WithProjectDefaults - var pipelineStepAnnotations = resource.Annotations.OfType().ToList(); - Assert.NotEmpty(pipelineStepAnnotations); - } - private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles { } From 5a873d6cc6cbcc88b900f038815eec856cbea9ff Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 15:09:23 -0500 Subject: [PATCH 11/17] Clean up code. --- .../ApplicationModel/ProjectResource.cs | 7 +++-- .../Publishing/ContainerRuntimeBase.cs | 26 +++++++++---------- .../Publishing/IContainerRuntime.cs | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index ffb0f4dadef..e2bcac9eb34 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -102,8 +102,7 @@ await containerImageBuilder.BuildImageAsync( ctx.CancellationToken).ConfigureAwait(false); // Check if we need to copy container files - var hasContainerFiles = this.TryGetAnnotationsOfType(out var containerFilesAnnotations); - if (!hasContainerFiles) + if (!this.TryGetAnnotationsOfType(out var containerFilesAnnotations)) { // No container files to copy, just build the image normally return; @@ -131,7 +130,7 @@ await containerImageBuilder.BuildImageAsync( var stage = dockerfileBuilder.From(tempImageName); // Add COPY --from: statements for each source - foreach (var containerFileDestination in containerFilesAnnotations!) + foreach (var containerFileDestination in containerFilesAnnotations) { var source = containerFileDestination.Source; @@ -159,7 +158,7 @@ await containerImageBuilder.BuildImageAsync( // Write the Dockerfile to a temporary location var projectMetadata = this.GetProjectMetadata(); var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; - var tempDockerfilePath = Path.Combine(projectDir, $"Dockerfile.{Guid.NewGuid():N}"); + var tempDockerfilePath = Path.GetTempFileName(); try { diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 5c740abd8c8..6802dcb2e35 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -50,28 +50,28 @@ await ExecuteContainerCommandAsync( localImageName, targetImageName).ConfigureAwait(false); } - public virtual async Task PushImageAsync(string imageName, CancellationToken cancellationToken) + public virtual async Task RemoveImageAsync(string imageName, CancellationToken cancellationToken) { - var arguments = $"push \"{imageName}\""; - + var arguments = $"rmi \"{imageName}\""; + await ExecuteContainerCommandAsync( - arguments, - $"{Name} push for {{ImageName}} failed with exit code {{ExitCode}}.", - $"{Name} push for {{ImageName}} succeeded.", - $"{Name} push failed with exit code {{0}}.", + arguments, + $"{Name} rmi for {{ImageName}} failed with exit code {{ExitCode}}.", + $"{Name} rmi for {{ImageName}} succeeded.", + $"{Name} rmi failed with exit code {{0}}.", cancellationToken, imageName).ConfigureAwait(false); } - public virtual async Task RemoveImageAsync(string imageName, CancellationToken cancellationToken) + public virtual async Task PushImageAsync(string imageName, CancellationToken cancellationToken) { - var arguments = $"rmi \"{imageName}\""; + var arguments = $"push \"{imageName}\""; await ExecuteContainerCommandAsync( arguments, - $"{Name} rmi for {{ImageName}} failed with exit code {{ExitCode}}.", - $"{Name} rmi for {{ImageName}} succeeded.", - $"{Name} rmi failed with exit code {{0}}.", + $"{Name} push for {{ImageName}} failed with exit code {{ExitCode}}.", + $"{Name} push for {{ImageName}} succeeded.", + $"{Name} push failed with exit code {{0}}.", cancellationToken, imageName).ConfigureAwait(false); } @@ -237,4 +237,4 @@ private ProcessSpec CreateProcessSpec(string arguments) InheritEnv = true }; } -} \ No newline at end of file +} diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index a445e6df42e..4eced69788a 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -11,6 +11,6 @@ internal interface IContainerRuntime Task CheckIfRunningAsync(CancellationToken cancellationToken); Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken); - Task PushImageAsync(string imageName, CancellationToken cancellationToken); Task RemoveImageAsync(string imageName, CancellationToken cancellationToken); + Task PushImageAsync(string imageName, CancellationToken cancellationToken); } From 9f4defe3c4369d5c1d02d6f989d75550f9af6a83 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 16:34:54 -0500 Subject: [PATCH 12/17] Clean up code Add more tests --- .../ApplicationModel/ProjectResource.cs | 9 +- .../DistributedApplicationBuilder.cs | 11 ++- .../ResourceContainerImageBuilder.cs | 12 +-- .../ProvisioningTestHelpers.cs | 45 ---------- .../Aspire.Hosting.Tests/MockImageBuilder.cs | 53 ++++++++++++ .../ProjectResourceTests.cs | 53 +++++++++++- .../Publishing/FakeContainerRuntime.cs | 82 +++++++++++++++++++ .../ResourceContainerImageBuilderTests.cs | 67 +-------------- 8 files changed, 201 insertions(+), 131 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/MockImageBuilder.cs create mode 100644 tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index e2bcac9eb34..7ae4f2bf120 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -7,12 +7,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel.Docker; -using Aspire.Hosting.Dcp; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Aspire.Hosting.ApplicationModel; @@ -115,12 +113,7 @@ await containerImageBuilder.BuildImageAsync( var tempTag = $"temp-{Guid.NewGuid():N}"; var tempImageName = $"{originalImageName}:{tempTag}"; - var dcpOptions = ctx.Services.GetRequiredService>(); - var containerRuntime = dcpOptions.Value.ContainerRuntime switch - { - string rt => ctx.Services.GetRequiredKeyedService(rt), - null => ctx.Services.GetRequiredKeyedService("docker") - }; + var containerRuntime = ctx.Services.GetRequiredService(); logger.LogDebug("Tagging image {OriginalImageName} as {TempImageName}", originalImageName, tempImageName); await containerRuntime.TagImageAsync(originalImageName, tempImageName, ctx.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 4f34db02115..1169ad3d00a 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -14,7 +14,6 @@ using Aspire.Hosting.Cli; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; -using Aspire.Hosting.Pipelines; using Aspire.Hosting.Devcontainers; using Aspire.Hosting.Devcontainers.Codespaces; using Aspire.Hosting.Eventing; @@ -22,6 +21,7 @@ using Aspire.Hosting.Health; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Orchestrator; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.VersionChecking; using Microsoft.Extensions.Configuration; @@ -450,6 +450,15 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) Eventing.Subscribe(BuiltInDistributedApplicationEventSubscriptionHandlers.MutateHttp2TransportAsync); _innerBuilder.Services.AddKeyedSingleton("docker"); _innerBuilder.Services.AddKeyedSingleton("podman"); + _innerBuilder.Services.AddSingleton(sp => + { + var dcpOptions = sp.GetRequiredService>(); + return dcpOptions.Value.ContainerRuntime switch + { + string rt => sp.GetRequiredKeyedService(rt), + null => sp.GetRequiredKeyedService("docker") + }; + }); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs index 902fa070ac1..adf56cc6f5f 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs @@ -7,11 +7,8 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Process; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Aspire.Hosting.Publishing; @@ -132,18 +129,13 @@ public interface IResourceContainerImageBuilder internal sealed class ResourceContainerImageBuilder( ILogger logger, - IOptions dcpOptions, + IContainerRuntime containerRuntime, IServiceProvider serviceProvider) : IResourceContainerImageBuilder { // Disable concurrent builds for project resources to avoid issues with overlapping msbuild projects private readonly SemaphoreSlim _throttle = new(1); - private IContainerRuntime? _containerRuntime; - private IContainerRuntime ContainerRuntime => _containerRuntime ??= dcpOptions.Value.ContainerRuntime switch - { - string rt => serviceProvider.GetRequiredKeyedService(rt), - null => serviceProvider.GetRequiredKeyedService("docker") - }; + private IContainerRuntime ContainerRuntime { get; } = containerRuntime; public async Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) { diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index e7f146a2aaf..395af5d5a90 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -678,48 +678,3 @@ private sealed class NoOpAsyncDisposable : IAsyncDisposable public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } - -/// -/// Mock implementation of IResourceContainerImageBuilder for testing. -/// -internal sealed class MockImageBuilder : IResourceContainerImageBuilder -{ - public bool BuildImageCalled { get; private set; } - public bool BuildImagesCalled { get; private set; } - public bool TagImageCalled { get; private set; } - public bool PushImageCalled { get; private set; } - public List BuildImageResources { get; } = []; - public List BuildImageOptions { get; } = []; - public List<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; - public List PushImageCalls { get; } = []; - - public Task BuildImageAsync(ApplicationModel.IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) - { - BuildImageCalled = true; - BuildImageResources.Add(resource); - BuildImageOptions.Add(options); - return Task.CompletedTask; - } - - public Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) - { - BuildImagesCalled = true; - BuildImageResources.AddRange(resources); - BuildImageOptions.Add(options); - return Task.CompletedTask; - } - - public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken = default) - { - TagImageCalled = true; - TagImageCalls.Add((localImageName, targetImageName)); - return Task.CompletedTask; - } - - public Task PushImageAsync(string imageName, CancellationToken cancellationToken = default) - { - PushImageCalled = true; - PushImageCalls.Add(imageName); - return Task.CompletedTask; - } -} diff --git a/tests/Aspire.Hosting.Tests/MockImageBuilder.cs b/tests/Aspire.Hosting.Tests/MockImageBuilder.cs new file mode 100644 index 00000000000..364ca27f0b8 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/MockImageBuilder.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#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. + +using Aspire.Hosting.Publishing; + +namespace Aspire.Hosting.Tests; + +/// +/// Mock implementation of IResourceContainerImageBuilder for testing. +/// +public sealed class MockImageBuilder : IResourceContainerImageBuilder +{ + public bool BuildImageCalled { get; private set; } + public bool BuildImagesCalled { get; private set; } + public bool TagImageCalled { get; private set; } + public bool PushImageCalled { get; private set; } + public List BuildImageResources { get; } = []; + public List BuildImageOptions { get; } = []; + public List<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; + public List PushImageCalls { get; } = []; + + public Task BuildImageAsync(IResource resource, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) + { + BuildImageCalled = true; + BuildImageResources.Add(resource); + BuildImageOptions.Add(options); + return Task.CompletedTask; + } + + public Task BuildImagesAsync(IEnumerable resources, ContainerBuildOptions? options = null, CancellationToken cancellationToken = default) + { + BuildImagesCalled = true; + BuildImageResources.AddRange(resources); + BuildImageOptions.Add(options); + return Task.CompletedTask; + } + + public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken = default) + { + TagImageCalled = true; + TagImageCalls.Add((localImageName, targetImageName)); + return Task.CompletedTask; + } + + public Task PushImageAsync(string imageName, CancellationToken cancellationToken = default) + { + PushImageCalled = true; + PushImageCalls.Add(imageName); + return Task.CompletedTask; + } +} diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 7cd1c3cce14..274cc717a95 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -3,18 +3,21 @@ #pragma warning disable CS0618 // Type or member is obsolete #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES003 using System.Text; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Aspire.Hosting.Testing; using Aspire.Hosting.Tests.Helpers; +using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Aspire.Hosting.Tests; @@ -792,11 +795,55 @@ public void ProjectResourceWithContainerFilesDestinationAnnotationCreatesPipelin // Verify the ContainerFilesDestinationAnnotation was added var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType()); Assert.Equal(sourceContainer.Resource, containerFilesAnnotation.Source); - Assert.Equal("./static", containerFilesAnnotation.DestinationPath); + Assert.Equal("./wwwroot", containerFilesAnnotation.DestinationPath); - // Verify the PipelineStepAnnotation was added by WithProjectDefaults var pipelineStepAnnotations = resource.Annotations.OfType().ToList(); - Assert.NotEmpty(pipelineStepAnnotations); + Assert.Single(pipelineStepAnnotations); + } + + [Fact] + public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: "build-projectName"); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Create a test container resource that implements IResourceWithContainerFiles + var sourceContainerResource = new TestContainerFilesResource("source"); + var sourceContainer = builder.AddResource(sourceContainerResource) + .WithImage("myimage") + .WithAnnotation(new ContainerFilesSourceAnnotation { SourcePath = "/app/dist" }); + + // Add a project and annotate it with ContainerFilesDestinationAnnotation + builder.AddProject("projectName", launchProfileName: null) + .PublishWithContainerFiles(sourceContainer, "./wwwroot"); + + using var app = builder.Build(); + await app.StartAsync(); + await app.WaitForShutdownAsync(); + + var mockImageBuilder = (MockImageBuilder)app.Services.GetRequiredService(); + Assert.True(mockImageBuilder.BuildImageCalled); + var builtImage = Assert.Single(mockImageBuilder.BuildImageResources); + Assert.Equal("projectName", builtImage.Name); + Assert.False(mockImageBuilder.PushImageCalled); + + var fakeContainerRuntime = (FakeContainerRuntime)app.Services.GetRequiredService(); + Assert.True(fakeContainerRuntime.WasTagImageCalled); + var tagCall = Assert.Single(fakeContainerRuntime.TagImageCalls); + Assert.Equal("projectname", tagCall.localImageName); + Assert.StartsWith("projectname:temp-", tagCall.targetImageName); + + Assert.True(fakeContainerRuntime.WasBuildImageCalled); + var buildCall = Assert.Single(fakeContainerRuntime.BuildImageCalls); + Assert.Equal("projectname", buildCall.imageName); + Assert.Empty(buildCall.contextPath); + Assert.NotEmpty(buildCall.dockerfilePath); + + Assert.True(fakeContainerRuntime.WasRemoveImageCalled); + var removeCall = Assert.Single(fakeContainerRuntime.RemoveImageCalls); + Assert.StartsWith("projectname:temp-", removeCall); + Assert.Equal(tagCall.targetImageName, removeCall); } internal static IDistributedApplicationBuilder CreateBuilder(string[]? args = null, DistributedApplicationOperation operation = DistributedApplicationOperation.Publish) diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs new file mode 100644 index 00000000000..071fb3233f7 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -0,0 +1,82 @@ +// 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.Publishing; + +#pragma warning disable ASPIREPIPELINES003 + +namespace Aspire.Hosting.Tests.Publishing; + +internal sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRuntime +{ + public string Name => "fake-runtime"; + public bool WasHealthCheckCalled { get; private set; } + public bool WasTagImageCalled { get; private set; } + public bool WasRemoveImageCalled { get; private set; } + public bool WasPushImageCalled { get; private set; } + public bool WasBuildImageCalled { get; private set; } + public List<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; + public List RemoveImageCalls { get; } = []; + public List PushImageCalls { get; } = []; + public List<(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options)> BuildImageCalls { get; } = []; + public Dictionary? CapturedBuildArguments { get; private set; } + public Dictionary? CapturedBuildSecrets { get; private set; } + public string? CapturedStage { get; private set; } + + public Task CheckIfRunningAsync(CancellationToken cancellationToken) + { + WasHealthCheckCalled = true; + return Task.FromResult(!shouldFail); + } + + public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) + { + WasTagImageCalled = true; + TagImageCalls.Add((localImageName, targetImageName)); + if (shouldFail) + { + throw new InvalidOperationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task RemoveImageAsync(string imageName, CancellationToken cancellationToken) + { + WasRemoveImageCalled = true; + RemoveImageCalls.Add(imageName); + if (shouldFail) + { + throw new InvalidOperationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task PushImageAsync(string imageName, CancellationToken cancellationToken) + { + WasPushImageCalled = true; + PushImageCalls.Add(imageName); + if (shouldFail) + { + throw new InvalidOperationException("Fake container runtime is configured to fail"); + } + return Task.CompletedTask; + } + + public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + { + // Capture the arguments for verification in tests + CapturedBuildArguments = buildArguments; + CapturedBuildSecrets = buildSecrets; + CapturedStage = stage; + WasBuildImageCalled = true; + BuildImageCalls.Add((contextPath, dockerfilePath, imageName, options)); + + if (shouldFail) + { + throw new InvalidOperationException("Fake container runtime is configured to fail"); + } + + // For testing, we don't need to actually build anything + return Task.CompletedTask; + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs index bc08bb83858..5687be28ffb 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs @@ -803,15 +803,15 @@ public async Task ResolveValue_FormatsDecimalWithInvariantCulture() // Test decimal value var result = await ResourceContainerImageBuilder.ResolveValue(3.14, CancellationToken.None); Assert.Equal("3.14", result); - + // Test double value result = await ResourceContainerImageBuilder.ResolveValue(3.14d, CancellationToken.None); Assert.Equal("3.14", result); - + // Test float value result = await ResourceContainerImageBuilder.ResolveValue(3.14f, CancellationToken.None); Assert.Equal("3.14", result); - + // Test integer (should also work) result = await ResourceContainerImageBuilder.ResolveValue(42, CancellationToken.None); Assert.Equal("42", result); @@ -860,65 +860,4 @@ public async Task CanResolveBuildSecretsWithDifferentValueTypes() // Null parameter should resolve to null Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"]); } - - private sealed class FakeContainerRuntime(bool shouldFail) : IContainerRuntime - { - public string Name => "fake-runtime"; - public bool WasHealthCheckCalled { get; private set; } - public bool WasTagImageCalled { get; private set; } - public bool WasPushImageCalled { get; private set; } - public bool WasBuildImageCalled { get; private set; } - public List<(string localImageName, string targetImageName)> TagImageCalls { get; } = []; - public List PushImageCalls { get; } = []; - public List<(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options)> BuildImageCalls { get; } = []; - public Dictionary? CapturedBuildArguments { get; private set; } - public Dictionary? CapturedBuildSecrets { get; private set; } - public string? CapturedStage { get; private set; } - - public Task CheckIfRunningAsync(CancellationToken cancellationToken) - { - WasHealthCheckCalled = true; - return Task.FromResult(!shouldFail); - } - - public Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) - { - WasTagImageCalled = true; - TagImageCalls.Add((localImageName, targetImageName)); - if (shouldFail) - { - throw new InvalidOperationException("Fake container runtime is configured to fail"); - } - return Task.CompletedTask; - } - - public Task PushImageAsync(string imageName, CancellationToken cancellationToken) - { - WasPushImageCalled = true; - PushImageCalls.Add(imageName); - if (shouldFail) - { - throw new InvalidOperationException("Fake container runtime is configured to fail"); - } - return Task.CompletedTask; - } - - public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) - { - // Capture the arguments for verification in tests - CapturedBuildArguments = buildArguments; - CapturedBuildSecrets = buildSecrets; - CapturedStage = stage; - WasBuildImageCalled = true; - BuildImageCalls.Add((contextPath, dockerfilePath, imageName, options)); - - if (shouldFail) - { - throw new InvalidOperationException("Fake container runtime is configured to fail"); - } - - // For testing, we don't need to actually build anything - return Task.CompletedTask; - } - } } From 8a916f76e20baafe2f45c782faa21687576db571 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 17:20:29 -0500 Subject: [PATCH 13/17] Fix the playground app --- .../default.conf.template | 5 ++-- .../AspireJavaScript.Angular/proxy.conf.js | 5 +--- .../AspireJavaScript.AppHost/AppHost.cs | 27 +++++++++++++++++-- .../AspireJavaScript.AppHost.csproj | 1 - .../default.conf.template | 3 +-- .../AspireJavaScript.React/webpack.config.js | 1 - .../default.conf.template | 1 - .../default.conf.template | 3 +-- .../AspireJavaScript.Vue/vite.config.ts | 1 - 9 files changed, 30 insertions(+), 17 deletions(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template index 18408d72742..d91b9208459 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template @@ -11,10 +11,9 @@ server { } location /api/ { - proxy_pass ${WEATHERAPI_HTTPS}; + proxy_pass ${WEATHERAPI_HTTPS}; # keep trailing slash off to preserve /api proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - rewrite ^/api(/.*)$ $1 break; } -} \ No newline at end of file +} diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Angular/proxy.conf.js b/playground/AspireWithJavaScript/AspireJavaScript.Angular/proxy.conf.js index f047c0c4efb..85aee6f66cc 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Angular/proxy.conf.js +++ b/playground/AspireWithJavaScript/AspireJavaScript.Angular/proxy.conf.js @@ -2,9 +2,6 @@ module.exports = { "/api": { target: process.env["WEATHERAPI_HTTPS"] || process.env["WEATHERAPI_HTTP"], - secure: process.env["NODE_ENV"] !== "development", - pathRewrite: { - "^/api": "", - }, + secure: process.env["NODE_ENV"] !== "development" }, }; diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index 605df864e89..b960eb39f0d 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -1,10 +1,33 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddAzureContainerAppEnvironment("env"); - var weatherApi = builder.AddProject("weatherapi") .WithExternalHttpEndpoints(); +builder.AddNpmApp("angular", "../AspireJavaScript.Angular") + .WithNpm(install: true) + .WithReference(weatherApi) + .WaitFor(weatherApi) + .WithHttpEndpoint(env: "PORT") + .WithExternalHttpEndpoints() + .PublishAsDockerFile(); + +builder.AddNpmApp("react", "../AspireJavaScript.React") + .WithNpm(install: true) + .WithReference(weatherApi) + .WaitFor(weatherApi) + .WithEnvironment("BROWSER", "none") // Disable opening browser on npm start + .WithHttpEndpoint(env: "PORT") + .WithExternalHttpEndpoints() + .PublishAsDockerFile(); + +builder.AddNpmApp("vue", "../AspireJavaScript.Vue") + .WithInstallCommand("npm", ["ci"]) // Use 'npm ci' for clean install + .WithReference(weatherApi) + .WaitFor(weatherApi) + .WithHttpEndpoint(env: "PORT") + .WithExternalHttpEndpoints() + .PublishAsDockerFile(); + var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") .WithNpm(install: true) .WithReference(weatherApi) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj index 5b5f0fd490e..262a263f6d4 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj @@ -12,7 +12,6 @@ - diff --git a/playground/AspireWithJavaScript/AspireJavaScript.React/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.React/default.conf.template index 18408d72742..113083ff80c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.React/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.React/default.conf.template @@ -15,6 +15,5 @@ server { proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - rewrite ^/api(/.*)$ $1 break; } -} \ No newline at end of file +} diff --git a/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js b/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js index 82c1a1a58e8..016c52e1674 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js +++ b/playground/AspireWithJavaScript/AspireJavaScript.React/webpack.config.js @@ -11,7 +11,6 @@ module.exports = (env) => { context: ["/api"], target: process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP, - pathRewrite: { "^/api": "" }, secure: false, }, ], diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vite/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.Vite/default.conf.template index 5689b97e3ac..94ea825cb3d 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Vite/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.Vite/default.conf.template @@ -15,6 +15,5 @@ server { proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - rewrite ^/api(/.*)$ $1 break; } } diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vue/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.Vue/default.conf.template index 18408d72742..113083ff80c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Vue/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.Vue/default.conf.template @@ -15,6 +15,5 @@ server { proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - rewrite ^/api(/.*)$ $1 break; } -} \ No newline at end of file +} diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vue/vite.config.ts b/playground/AspireWithJavaScript/AspireJavaScript.Vue/vite.config.ts index dfd67f0c9db..91a0b76f9a4 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Vue/vite.config.ts +++ b/playground/AspireWithJavaScript/AspireJavaScript.Vue/vite.config.ts @@ -20,7 +20,6 @@ export default defineConfig({ '/api': { target: process.env.WEATHERAPI_HTTPS || process.env.WEATHERAPI_HTTP, changeOrigin: true, - rewrite: path => path.replace(/^\/api/, ''), secure: false } } From 08c377e12047f6532fc1c7b55ba8b3f6ac05ef1e Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 17:50:57 -0500 Subject: [PATCH 14/17] Address PR feedback --- .../ApplicationModel/ProjectResource.cs | 29 ++++++++++++------- .../ProjectResourceBuilderExtensions.cs | 3 -- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 7ae4f2bf120..2a145ac52e6 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -88,7 +88,7 @@ internal bool ShouldInjectEndpointEnvironment(EndpointReference e) private async Task BuildProjectImage(PipelineStepContext ctx) { var containerImageBuilder = ctx.Services.GetRequiredService(); - var logger = ctx.Services.GetRequiredService>(); + var logger = ctx.Logger; // Build the container image for the project first await containerImageBuilder.BuildImageAsync( @@ -153,6 +153,7 @@ await containerImageBuilder.BuildImageAsync( var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; var tempDockerfilePath = Path.GetTempFileName(); + var builtSuccessfully = false; try { using (var writer = new StreamWriter(tempDockerfilePath)) @@ -177,22 +178,30 @@ await containerRuntime.BuildImageAsync( ctx.CancellationToken).ConfigureAwait(false); logger.LogDebug("Successfully built final image {ImageName} with container files", originalImageName); + builtSuccessfully = true; } finally { - // Clean up the temporary Dockerfile - if (File.Exists(tempDockerfilePath)) + if (builtSuccessfully) { - try + // Clean up the temporary Dockerfile + if (File.Exists(tempDockerfilePath)) { - File.Delete(tempDockerfilePath); - logger.LogDebug("Deleted temporary Dockerfile {DockerfilePath}", tempDockerfilePath); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to delete temporary Dockerfile {DockerfilePath}", 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 + { + logger.LogWarning("Failed build Dockerfile {DockerfilePath}", tempDockerfilePath); + } // Remove the temporary tagged image logger.LogDebug("Removing temporary image {TempImageName}", tempImageName); diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 5d552d4fa57..a2b5fab4ea3 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREEXTENSION001 -#pragma warning disable ASPIREPIPELINES001 -#pragma warning disable ASPIREPIPELINES003 -#pragma warning disable ASPIREDOCKERFILEBUILDER001 using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; From 315c556430f4648b1652c41da310066464ed6bd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Oct 2025 22:59:29 +0000 Subject: [PATCH 15/17] Address PR feedback: Get ContainerWorkingDirectory from project and improve debugging - Get $(ContainerWorkingDirectory) from project using dotnet msbuild instead of hardcoding /app - Add GetContainerWorkingDirectoryAsync helper method with fallback to /app - Keep temporary Dockerfile on build failure for debugging purposes - Log debug message with path to temporary Dockerfile when build fails Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ApplicationModel/ProjectResource.cs | 66 ++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 2a145ac52e6..7e2dee76ba2 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -122,6 +122,9 @@ await containerImageBuilder.BuildImageAsync( var dockerfileBuilder = new DockerfileBuilder(); var stage = dockerfileBuilder.From(tempImageName); + // Get the container working directory for the project + var containerWorkingDir = await GetContainerWorkingDirectoryAsync(logger, ctx.CancellationToken).ConfigureAwait(false); + // Add COPY --from: statements for each source foreach (var containerFileDestination in containerFilesAnnotations) { @@ -136,8 +139,8 @@ await containerImageBuilder.BuildImageAsync( var destinationPath = containerFileDestination.DestinationPath; if (!destinationPath.StartsWith('/')) { - // Make it an absolute path relative to /app (typical .NET container working directory) - destinationPath = $"/app/{destinationPath}"; + // Make it an absolute path relative to the container working directory + destinationPath = $"{containerWorkingDir}/{destinationPath}"; } foreach (var containerFilesSource in source.Annotations.OfType()) @@ -200,7 +203,8 @@ await containerRuntime.BuildImageAsync( } else { - logger.LogWarning("Failed build Dockerfile {DockerfilePath}", tempDockerfilePath); + // Keep the Dockerfile for debugging purposes + logger.LogDebug("Failed build - temporary Dockerfile left at {DockerfilePath} for debugging", tempDockerfilePath); } // Remove the temporary tagged image @@ -208,4 +212,60 @@ await containerRuntime.BuildImageAsync( await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false); } } + + private async Task GetContainerWorkingDirectoryAsync(ILogger logger, CancellationToken cancellationToken) + { + try + { + var projectMetadata = this.GetProjectMetadata(); + var projectPath = projectMetadata.ProjectPath; + + var outputLines = new List(); + 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"; + } + } } From 50a4c13b48bb76eb61d73f7c850cf6b6e44b2d69 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 18:01:20 -0500 Subject: [PATCH 16/17] Apply suggestion from @eerhardt --- .../AspireJavaScript.Angular/default.conf.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template index d91b9208459..113083ff80c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template @@ -11,7 +11,7 @@ server { } location /api/ { - proxy_pass ${WEATHERAPI_HTTPS}; # keep trailing slash off to preserve /api + proxy_pass ${WEATHERAPI_HTTPS}; proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; From c8f858161d5808726a9a95bc92bb2915c36096f7 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 28 Oct 2025 18:11:54 -0500 Subject: [PATCH 17/17] Refactor copilots code. --- src/Aspire.Hosting/ApplicationModel/ProjectResource.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 7e2dee76ba2..17cdd191d32 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -122,8 +122,10 @@ await containerImageBuilder.BuildImageAsync( var dockerfileBuilder = new DockerfileBuilder(); var stage = dockerfileBuilder.From(tempImageName); + var projectMetadata = this.GetProjectMetadata(); + // Get the container working directory for the project - var containerWorkingDir = await GetContainerWorkingDirectoryAsync(logger, ctx.CancellationToken).ConfigureAwait(false); + var containerWorkingDir = await GetContainerWorkingDirectoryAsync(projectMetadata.ProjectPath, logger, ctx.CancellationToken).ConfigureAwait(false); // Add COPY --from: statements for each source foreach (var containerFileDestination in containerFilesAnnotations) @@ -152,7 +154,6 @@ await containerImageBuilder.BuildImageAsync( } // Write the Dockerfile to a temporary location - var projectMetadata = this.GetProjectMetadata(); var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; var tempDockerfilePath = Path.GetTempFileName(); @@ -213,13 +214,10 @@ await containerRuntime.BuildImageAsync( } } - private async Task GetContainerWorkingDirectoryAsync(ILogger logger, CancellationToken cancellationToken) + private static async Task GetContainerWorkingDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken) { try { - var projectMetadata = this.GetProjectMetadata(); - var projectPath = projectMetadata.ProjectPath; - var outputLines = new List(); var spec = new Dcp.Process.ProcessSpec("dotnet") {