diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template b/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template index 18408d72742..113083ff80c 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.Angular/default.conf.template +++ b/playground/AspireWithJavaScript/AspireJavaScript.Angular/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.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 0016647208b..b960eb39f0d 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -28,10 +28,12 @@ .WithExternalHttpEndpoints() .PublishAsDockerFile(); -builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") +var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") .WithNpm(install: true) .WithReference(weatherApi) .WithEnvironment("BROWSER", "none") .WithExternalHttpEndpoints(); +weatherApi.PublishWithContainerFiles(reactvite, "./wwwroot"); + builder.Build().Run(); diff --git a/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.MinimalApi/AppHost.cs index cafd6bbc0cb..784b93baeab 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,8 @@ .WithName("GetWeatherForecast") .WithOpenApi(); +app.UseFileServer(); + app.Run(); sealed record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) 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.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/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 } } diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index 5c17385a850..17cdd191d32 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -1,13 +1,16 @@ #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.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -34,17 +37,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 +45,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 +84,186 @@ 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.Logger; + + // Build the container image for the project first + await containerImageBuilder.BuildImageAsync( + this, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + ctx.CancellationToken).ConfigureAwait(false); + + // Check if we need to copy container files + if (!this.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + // No container files to copy, just build the image normally + return; + } + + // Get the built image name + var originalImageName = Name.ToLowerInvariant(); + + // Tag the built image with a temporary tag + var tempTag = $"temp-{Guid.NewGuid():N}"; + var tempImageName = $"{originalImageName}:{tempTag}"; + + var containerRuntime = ctx.Services.GetRequiredService(); + + 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); + + var projectMetadata = this.GetProjectMetadata(); + + // Get the container working directory for the project + var containerWorkingDir = await GetContainerWorkingDirectoryAsync(projectMetadata.ProjectPath, logger, ctx.CancellationToken).ConfigureAwait(false); + + // Add COPY --from: statements for each source + 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 the container working directory + destinationPath = $"{containerWorkingDir}/{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 projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; + var tempDockerfilePath = Path.GetTempFileName(); + + var builtSuccessfully = false; + try + { + using (var writer = new StreamWriter(tempDockerfilePath)) + { + await dockerfileBuilder.WriteAsync(writer, ctx.CancellationToken).ConfigureAwait(false); + } + + logger.LogDebug("Generated temporary Dockerfile at {DockerfilePath}", tempDockerfilePath); + + // Build the final image from the generated Dockerfile + await containerRuntime.BuildImageAsync( + projectDir, + tempDockerfilePath, + originalImageName, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + [], + [], + null, + ctx.CancellationToken).ConfigureAwait(false); + + logger.LogDebug("Successfully built final image {ImageName} with container files", originalImageName); + builtSuccessfully = true; + } + finally + { + if (builtSuccessfully) + { + // Clean up the temporary Dockerfile + if (File.Exists(tempDockerfilePath)) + { + try + { + File.Delete(tempDockerfilePath); + logger.LogDebug("Deleted temporary Dockerfile {DockerfilePath}", tempDockerfilePath); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete temporary Dockerfile {DockerfilePath}", tempDockerfilePath); + } + } + } + else + { + // Keep the Dockerfile for debugging purposes + logger.LogDebug("Failed build - temporary Dockerfile left at {DockerfilePath} for debugging", tempDockerfilePath); + } + + // Remove the temporary tagged image + logger.LogDebug("Removing temporary image {TempImageName}", tempImageName); + await containerRuntime.RemoveImageAsync(tempImageName, ctx.CancellationToken).ConfigureAwait(false); + } + } + + private static async Task GetContainerWorkingDirectoryAsync(string projectPath, ILogger logger, CancellationToken cancellationToken) + { + try + { + 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"; + } + } } 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/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index 3c2df71da7a..6802dcb2e35 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -50,6 +50,19 @@ await ExecuteContainerCommandAsync( localImageName, targetImageName).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); + } + public virtual async Task PushImageAsync(string imageName, CancellationToken cancellationToken) { var arguments = $"push \"{imageName}\""; @@ -224,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 8858f556aff..4eced69788a 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -11,5 +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 RemoveImageAsync(string imageName, CancellationToken cancellationToken); Task PushImageAsync(string imageName, CancellationToken cancellationToken); } 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 efec63643c1..274cc717a95 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -2,19 +2,22 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable CS0618 // Type or member is obsolete -#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 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; @@ -767,6 +770,82 @@ 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("./wwwroot", containerFilesAnnotation.DestinationPath); + + var pipelineStepAnnotations = resource.Annotations.OfType().ToList(); + 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) { var resolvedArgs = new List(); @@ -931,4 +1010,8 @@ public TestProjectWithExecutableProfile() }; } } + + private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles + { + } } 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; - } - } }