diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 3c717fbde5d..e4d5f9b9731 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -126,11 +126,14 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext => { - var logger = dockerfileContext.Services.GetService>() ?? NullLogger.Instance; - var nodeVersion = DetectNodeVersion(appDirectory, logger) ?? DefaultNodeVersion; + var defaultBaseImage = new Lazy(() => GetDefaultBaseImage(appDirectory, "alpine", dockerfileContext.Services)); + // Get custom base image from annotation, if present + dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation); + + var baseBuildImage = baseImageAnnotation?.BuildImage ?? defaultBaseImage.Value; var builderStage = dockerfileContext.Builder - .From($"node:{nodeVersion}-alpine", "build") + .From(baseBuildImage, "build") .EmptyLine() .WorkDir("/app") .Copy(".", ".") @@ -157,8 +160,9 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl } } + var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? defaultBaseImage.Value; var runtimeBuilder = dockerfileContext.Builder - .From($"node:{nodeVersion}-alpine", "runtime") + .From(baseRuntimeImage, "runtime") .EmptyLine() .WorkDir("/app") .CopyFrom("build", "/app", "/app") @@ -313,10 +317,12 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder(out var packageManager)) { - var logger = dockerfileContext.Services.GetService>() ?? NullLogger.Instance; - var nodeVersion = DetectNodeVersion(appDirectory, logger) ?? DefaultNodeVersion; + // Get custom base image from annotation, if present + dockerfileContext.Resource.TryGetLastAnnotation(out var baseImageAnnotation); + var baseImage = baseImageAnnotation?.BuildImage ?? GetDefaultBaseImage(appDirectory, "slim", dockerfileContext.Services); + var dockerBuilder = dockerfileContext.Builder - .From($"node:{nodeVersion}-slim") + .From(baseImage) .WorkDir("/app") .Copy(".", "."); @@ -603,6 +609,13 @@ private static void AddInstaller(IResourceBuilder resource } } + private static string GetDefaultBaseImage(string appDirectory, string defaultSuffix, IServiceProvider serviceProvider) + { + var logger = serviceProvider.GetService>() ?? NullLogger.Instance; + var nodeVersion = DetectNodeVersion(appDirectory, logger) ?? DefaultNodeVersion; + return $"node:{nodeVersion}-{defaultSuffix}"; + } + /// /// Detects the Node.js version to use for a project by checking common configuration files. /// diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 3b22be3a5b0..f4734988f93 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -469,8 +469,13 @@ private static IResourceBuilder AddPythonAppCore( var uvLockPath = Path.Combine(resource.WorkingDirectory, "uv.lock"); var hasUvLock = File.Exists(uvLockPath); + // Get custom base images from annotation, if present + context.Resource.TryGetLastAnnotation(out var baseImageAnnotation); + var buildImage = baseImageAnnotation?.BuildImage ?? $"ghcr.io/astral-sh/uv:python{pythonVersion}-bookworm-slim"; + var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"python:{pythonVersion}-slim-bookworm"; + var builderStage = context.Builder - .From($"ghcr.io/astral-sh/uv:python{pythonVersion}-bookworm-slim", "builder") + .From(buildImage, "builder") .EmptyLine() .Comment("Enable bytecode compilation and copy mode for the virtual environment") .Env("UV_COMPILE_BYTECODE", "1") @@ -518,7 +523,7 @@ private static IResourceBuilder AddPythonAppCore( } var runtimeBuilder = context.Builder - .From($"python:{pythonVersion}-slim-bookworm", "app") + .From(runtimeImage, "app") .EmptyLine() .AddContainerFiles(context.Resource, "/app") .Comment("------------------------------") diff --git a/src/Aspire.Hosting/ApplicationModel/DockerfileBaseImageAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DockerfileBaseImageAnnotation.cs new file mode 100644 index 00000000000..d63256b1d5a --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/DockerfileBaseImageAnnotation.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation for specifying custom base images in generated Dockerfiles. +/// +/// +/// This annotation allows developers to override the default base images used when generating +/// Dockerfiles for resources. It supports specifying separate build-time and runtime base images +/// for multi-stage builds. +/// +[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class DockerfileBaseImageAnnotation : IResourceAnnotation +{ + /// + /// Gets or sets the base image to use for the build stage in multi-stage Dockerfiles. + /// + /// + /// This image is used during the build phase where dependencies are installed and + /// the application is compiled or prepared. If not specified, the default build image + /// for the resource type will be used. + /// + public string? BuildImage { get; set; } + + /// + /// Gets or sets the base image to use for the runtime stage in multi-stage Dockerfiles. + /// + /// + /// This image is used for the final runtime stage where the application actually runs. + /// If not specified, the default runtime image for the resource type will be used. + /// + public string? RuntimeImage { get; set; } +} diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs index 1b77f8bde13..d56d90fb399 100644 --- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs @@ -1427,6 +1427,50 @@ public static IResourceBuilder WithDockerfileBuilder(this IResourceBuilder return Task.CompletedTask; }, stage); } + + /// + /// Configures custom base images for generated Dockerfiles. + /// + /// The type of resource. + /// The resource builder. + /// The base image to use for the build stage. If null, uses the default build image. + /// The base image to use for the runtime stage. If null, uses the default runtime image. + /// The . + /// + /// + /// This extension method allows customization of the base images used in generated Dockerfiles. + /// For multi-stage Dockerfiles (e.g., Python with UV), you can specify separate build and runtime images. + /// + /// + /// Specify custom base images for a Python application: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddPythonApp("myapp", "path/to/app", "main.py") + /// .WithDockerfileBaseImage( + /// buildImage: "ghcr.io/astral-sh/uv:python3.12-bookworm-slim", + /// runtimeImage: "python:3.12-slim-bookworm"); + /// + /// builder.Build().Run(); + /// + /// + /// + [Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithDockerfileBaseImage(this IResourceBuilder builder, string? buildImage = null, string? runtimeImage = null) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + + if (buildImage is null && runtimeImage is null) + { + throw new ArgumentException($"At least one of {nameof(buildImage)} or {nameof(runtimeImage)} must be specified.", nameof(buildImage)); + } + + return builder.WithAnnotation(new DockerfileBaseImageAnnotation + { + BuildImage = buildImage, + RuntimeImage = runtimeImage + }, ResourceAnnotationMutationBehavior.Replace); + } } internal static class IListExtensions diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs index adf56cc6f5f..c1c43449670 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageBuilder.cs @@ -270,6 +270,14 @@ private async Task ExecuteDotnetPublishAsync(IResource resource, Container } } +#pragma warning disable ASPIREDOCKERFILEBUILDER001 + if (resource.TryGetLastAnnotation(out var baseImageAnnotation) && + baseImageAnnotation.RuntimeImage is string baseImage) + { + arguments += $" /p:ContainerBaseImage=\"{baseImage}\""; + } +#pragma warning restore ASPIREDOCKERFILEBUILDER001 + var spec = new ProcessSpec("dotnet") { Arguments = arguments, diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs index 922742d66ce..b9a6e313a81 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs @@ -1,6 +1,8 @@ // 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 ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; @@ -174,6 +176,30 @@ USER node Assert.Equal(expectedDockerfile, dockerfileContents); } + [Fact] + public async Task VerifyDockerfileWithCustomBaseImage() + { + using var tempDir = new TempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true); + + var appDir = Path.Combine(tempDir.Path, "js"); + Directory.CreateDirectory(appDir); + File.WriteAllText(Path.Combine(appDir, "package.json"), "{}"); + + var customBuildImage = "node:22-mySpecialBuildImage"; + var customRuntimeImage = "node:22-mySpecialRuntimeImage"; + var nodeApp = builder.AddNodeApp("js", appDir, "app.js") + .WithNpm(install: true) + .WithDockerfileBaseImage(customBuildImage, customRuntimeImage); + + await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path); + + // Verify the Dockerfile contains the custom base image + var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "js.Dockerfile")); + Assert.Contains($"FROM {customBuildImage}", dockerfileContents); + Assert.Contains($"FROM {customRuntimeImage}", dockerfileContents); + } + [Fact] public void AddNodeApp_DoesNotAddNpmWhenNoPackageJson() { diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs index e4977de5caa..a947df5e35c 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs @@ -1,6 +1,8 @@ // 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 ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only + using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; @@ -201,4 +203,25 @@ public async Task VerifyDockerfileHandlesVariousVersionFormats(string versionStr Assert.Contains($"FROM {expectedImage}", dockerfileContents); } + + [Fact] + public async Task VerifyCustomBaseImage() + { + using var tempDir = new TempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true); + + var customImage = "node:22-myspecialimage"; + var nodeApp = builder.AddViteApp("vite", tempDir.Path) + .WithNpm(install: true) + .WithDockerfileBaseImage(buildImage: customImage); + + var manifest = await ManifestUtils.GetManifest(nodeApp.Resource, tempDir.Path); + + // Verify the manifest structure + Assert.Equal("container.v1", manifest["type"]?.ToString()); + + // Verify the Dockerfile contains the custom base image + var dockerfileContents = File.ReadAllText(Path.Combine(tempDir.Path, "vite.Dockerfile")); + Assert.Contains($"FROM {customImage}", dockerfileContents); + } } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 6f918c8fe1e..422bdf45598 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -3,6 +3,7 @@ #pragma warning disable CS0612 #pragma warning disable CS0618 // Type or member is obsolete +#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only using Microsoft.Extensions.DependencyInjection; using Aspire.Hosting.Utils; @@ -1313,5 +1314,63 @@ public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_InPublishMod // PYTHONUTF8 should not be set in Publish mode, even on Windows Assert.False(environmentVariables.ContainsKey("PYTHONUTF8")); } + + [Fact] + public async Task WithUvEnvironment_CustomBaseImages_GeneratesDockerfileWithCustomImages() + { + using var sourceDir = new TempDirectory(); + using var outputDir = new TempDirectory(); + var projectDirectory = sourceDir.Path; + + // Create a UV-based Python project with pyproject.toml and uv.lock + var pyprojectContent = """ + [project] + name = "test-app" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + """; + + var uvLockContent = """ + version = 1 + requires-python = ">=3.12" + """; + + var scriptContent = """ + print("Hello from UV project with custom images!") + """; + + File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent); + File.WriteAllText(Path.Combine(projectDirectory, "uv.lock"), uvLockContent); + File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); + + // Add Python resource with custom base images + builder.AddPythonScript("custom-images-app", projectDirectory, "main.py") + .WithUvEnvironment() + .WithDockerfileBaseImage( + buildImage: "ghcr.io/astral-sh/uv:python3.13-bookworm", + runtimeImage: "python:3.13-slim"); + + var app = builder.Build(); + app.Run(); + + // Verify that Dockerfile was generated + var dockerfilePath = Path.Combine(outputDir.Path, "custom-images-app.Dockerfile"); + Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated"); + + var dockerfileContent = File.ReadAllText(dockerfilePath); + + // Verify the custom build image is used + Assert.Contains("FROM ghcr.io/astral-sh/uv:python3.13-bookworm AS builder", dockerfileContent); + + // Verify the custom runtime image is used + Assert.Contains("FROM python:3.13-slim AS app", dockerfileContent); + } } diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs index 5687be28ffb..c91a96ad8ff 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageBuilderTests.cs @@ -44,6 +44,39 @@ public async Task CanBuildImageFromProjectResource() Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0")); } + [Fact] + [RequiresDocker] + public async Task CanBuildImageFromProjectResourceWithCustomBaseImage() + { + using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(output); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + +#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. + var servicea = builder.AddProject("servicea") + .WithDockerfileBaseImage(runtimeImage: "mcr.microsoft.com/dotnet/sdk:8.0-alpine"); +#pragma warning restore ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var imageBuilder = app.Services.GetRequiredService(); + await imageBuilder.BuildImageAsync(servicea.Resource, options: null, cts.Token); + + // Validate that BuildImageAsync succeeded by checking the log output + var collector = app.Services.GetFakeLogCollector(); + var logs = collector.GetSnapshot(); + + // Check for success logs + Assert.Contains(logs, log => log.Message.Contains("Building container image for resource servicea")); + Assert.Contains(logs, log => log.Message.Contains("/p:ContainerBaseImage=\"mcr.microsoft.com/dotnet/sdk:8.0-alpine\"")); + Assert.Contains(logs, log => log.Message.Contains(".NET CLI completed with exit code: 0")); + } + [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/dnceng/issues/6232", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningFromAzdo))]