Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,16 @@ public static IResourceBuilder<NodeAppResource> AddNodeApp(this IDistributedAppl
}
}

var logger = dockerfileContext.Services.GetService<ILogger<JavaScriptAppResource>>();
dockerfileContext.Builder.AddContainerFilesStages(dockerfileContext.Resource, logger);

var baseRuntimeImage = baseImageAnnotation?.RuntimeImage ?? defaultBaseImage.Value;
var runtimeBuilder = dockerfileContext.Builder
.From(baseRuntimeImage, "runtime")
.EmptyLine()
.WorkDir("/app")
.CopyFrom("build", "/app", "/app")
.AddContainerFiles(dockerfileContext.Resource, "/app", dockerfileContext.Services.GetService<ILogger<JavaScriptAppResource>>())
.AddContainerFiles(dockerfileContext.Resource, "/app", logger)
.EmptyLine()
.Env("NODE_ENV", "production")
.Expose(3000)
Expand Down
10 changes: 8 additions & 2 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -550,10 +550,13 @@ private static void GenerateUvDockerfile(DockerfileBuilderCallbackContext contex
"type=cache,target=/root/.cache/uv");
}

var logger = context.Services.GetService<ILogger<PythonAppResource>>();
context.Builder.AddContainerFilesStages(context.Resource, logger);

var runtimeBuilder = context.Builder
.From(runtimeImage, "app")
.EmptyLine()
.AddContainerFiles(context.Resource, "/app", context.Services.GetService<ILogger<PythonAppResource>>())
.AddContainerFiles(context.Resource, "/app", logger)
.Comment("------------------------------")
.Comment("🚀 Runtime stage")
.Comment("------------------------------")
Expand Down Expand Up @@ -603,10 +606,13 @@ private static void GenerateFallbackDockerfile(DockerfileBuilderCallbackContext
var requirementsTxtPath = Path.Combine(resource.WorkingDirectory, "requirements.txt");
var hasRequirementsTxt = File.Exists(requirementsTxtPath);

var logger = context.Services.GetService<ILogger<PythonAppResource>>();
context.Builder.AddContainerFilesStages(context.Resource, logger);

var stage = context.Builder
.From(runtimeImage)
.EmptyLine()
.AddContainerFiles(context.Resource, "/app", context.Services.GetService<ILogger<PythonAppResource>>())
.AddContainerFiles(context.Resource, "/app", logger)
.Comment("------------------------------")
.Comment("🚀 Python Application")
.Comment("------------------------------")
Expand Down
6 changes: 5 additions & 1 deletion src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,14 @@ private static IResourceBuilder<YarpResource> EnsurePublishWithStaticFilesDocker

return builder.WithDockerfileBuilder(".", ctx =>
{
var logger = ctx.Services.GetService<ILogger<YarpResource>>();
var imageName = GetYarpImageName(ctx.Resource);

ctx.Builder.AddContainerFilesStages(ctx.Resource, logger);

ctx.Builder.From(imageName)
.WorkDir("/app")
.AddContainerFiles(ctx.Resource, "/app/wwwroot", ctx.Services.GetService<ILogger<YarpResource>>());
.AddContainerFiles(ctx.Resource, "/app/wwwroot", logger);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,47 @@
namespace Aspire.Hosting.ApplicationModel.Docker;

/// <summary>
/// Provides extension methods for <see cref="DockerfileStage"/>.
/// Provides Dockerfile builder extension methods for supporting <see cref="ResourceBuilderExtensions.PublishWithContainerFiles" />.
/// </summary>
public static class DockerfileStageExtensions
public static class ContainerFilesExtensions
{
/// <summary>
/// Adds Dockerfile instructions to include container files from the specified resource into the Dockerfile build
/// process.
/// </summary>
/// <param name="builder">The Dockerfile builder to which container file instructions will be added. Cannot be null.</param>
/// <param name="resource">The resource containing container files to be added to the Dockerfile. Cannot be null.</param>
/// <param name="logger">An optional logger used to record warnings if container image names cannot be determined for source resources.</param>
/// <returns>The same DockerfileBuilder instance with additional instructions for container files, enabling method chaining.</returns>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static DockerfileBuilder AddContainerFilesStages(this DockerfileBuilder builder, IResource resource, ILogger? logger)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(resource);

if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesDestinationAnnotations))
{
foreach (var containerFileDestination in containerFilesDestinationAnnotations)
{
var source = containerFileDestination.Source;

// get image name - skip this source if it doesn't have an image name
if (!source.TryGetContainerImageName(out var sourceImageName))
{
logger?.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name);
continue;
}

var sourceImageArgName = GetSourceImageArgName(source);
builder.Arg(sourceImageArgName, sourceImageName);

var sourceImageStageName = GetSourceStageName(source);
builder.From("${" + sourceImageArgName + "}", sourceImageStageName);
}
}
return builder;
}

/// <summary>
/// Adds COPY --from statements to the Dockerfile stage for container files from resources referenced by <see cref="ContainerFilesDestinationAnnotation"/>.
/// </summary>
Expand All @@ -28,7 +65,7 @@ public static class DockerfileStageExtensions
/// For each annotation:
/// <list type="bullet">
/// <item>If the source resource has a container image name (via <c>TryGetContainerImageName</c>), COPY statements are generated</item>
/// <item>If the source resource does not have a container image name, it is silently skipped</item>
/// <item>If the source resource does not have a container image name, it is skipped</item>
/// <item>Relative destination paths are combined with <paramref name="rootDestinationPath"/></item>
/// <item>Absolute destination paths are used as-is</item>
/// <item>Each <see cref="ContainerFilesSourceAnnotation"/> on the source resource generates a COPY statement</item>
Expand All @@ -53,12 +90,14 @@ public static DockerfileStage AddContainerFiles(this DockerfileStage stage, IRes
var source = containerFileDestination.Source;

// get image name - skip this source if it doesn't have an image name
if (!source.TryGetContainerImageName(out var sourceImageName))
if (!source.TryGetContainerImageName(out var _))
{
logger?.LogWarning("Cannot get container image name for source resource {SourceName}, skipping", source.Name);
continue;
}

var sourceImageStageName = GetSourceStageName(source);

var destinationPath = containerFileDestination.DestinationPath;
if (!destinationPath.StartsWith('/'))
{
Expand All @@ -68,13 +107,17 @@ public static DockerfileStage AddContainerFiles(this DockerfileStage stage, IRes
foreach (var containerFilesSource in source.Annotations.OfType<ContainerFilesSourceAnnotation>())
{
logger?.LogDebug("Adding COPY --from={SourceImage} {SourcePath} {DestinationPath}",
sourceImageName, containerFilesSource.SourcePath, destinationPath);
stage.CopyFrom(sourceImageName, containerFilesSource.SourcePath, destinationPath);
sourceImageStageName, containerFilesSource.SourcePath, destinationPath);
stage.CopyFrom(sourceImageStageName, containerFilesSource.SourcePath, destinationPath);
}
}

stage.EmptyLine();
}
return stage;
}

private static string GetSourceImageArgName(IResource source) => $"{source.Name.ToUpperInvariant()}_IMAGENAME";

private static string GetSourceStageName(IResource source) => $"{source.Name}_stage";
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace Aspire.Hosting.ApplicationModel.Docker;
public class DockerfileBuilder
{
private readonly List<DockerfileStage> _stages = [];
private readonly List<DockerfileArgStatement> _globalArgs = [];

/// <summary>
/// Initializes a new instance of the <see cref="DockerfileBuilder"/> class.
Expand All @@ -25,6 +26,44 @@ public DockerfileBuilder()
/// </summary>
public IReadOnlyList<DockerfileStage> Stages => _stages.AsReadOnly();

/// <summary>
/// Adds a global ARG statement to define a build-time variable before any stages.
/// </summary>
/// <param name="name">The name of the build argument.</param>
/// <returns>The current DockerfileBuilder instance for method chaining.</returns>
/// <remarks>
/// Global ARG statements appear before the first FROM statement and can be used
/// to parameterize the base image selection.
/// </remarks>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public DockerfileBuilder Arg(string name)
{
ArgumentException.ThrowIfNullOrEmpty(name);

_globalArgs.Add(new DockerfileArgStatement(name));
return this;
}

/// <summary>
/// Adds a global ARG statement to define a build-time variable with a default value before any stages.
/// </summary>
/// <param name="name">The name of the build argument.</param>
/// <param name="defaultValue">The default value for the build argument.</param>
/// <returns>The current DockerfileBuilder instance for method chaining.</returns>
/// <remarks>
/// Global ARG statements appear before the first FROM statement and can be used
/// to parameterize the base image selection.
/// </remarks>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public DockerfileBuilder Arg(string name, string defaultValue)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(defaultValue);

_globalArgs.Add(new DockerfileArgStatement(name, defaultValue));
return this;
}

/// <summary>
/// Adds a FROM statement to start a new named stage.
/// </summary>
Expand Down Expand Up @@ -66,6 +105,18 @@ public DockerfileStage From(string image)
public async Task WriteAsync(StreamWriter writer, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(writer);

// Write global ARG statements first
if (_globalArgs.Count > 0)
{
foreach (var arg in _globalArgs)
{
await arg.WriteStatementAsync(writer, cancellationToken).ConfigureAwait(false);
}

// Add a blank line after global args
await writer.WriteLineAsync().ConfigureAwait(false);
}

foreach (var stage in _stages)
{
Expand All @@ -80,4 +131,4 @@ public async Task WriteAsync(StreamWriter writer, CancellationToken cancellation

await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
}
2 changes: 2 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ await containerImageBuilder.BuildImageAsync(

// Generate a Dockerfile that layers the container files on top
var dockerfileBuilder = new DockerfileBuilder();
dockerfileBuilder.AddContainerFilesStages(this, logger);

var stage = dockerfileBuilder.From(tempImageName);

var projectMetadata = this.GetProjectMetadata();
Expand Down
4 changes: 1 addition & 3 deletions tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,7 @@ public async Task VerifyNodeAppWithContainerFilesGeneratesCorrectDockerfile()

var dockerfileContent = File.ReadAllText(nodeDockerfilePath);

// Verify that the Dockerfile includes the COPY --from statement for container files
// Note: The image name is prefixed with the resource name, so it's "source:source-tag"
Assert.Contains("COPY --from=source:source-tag /app/dist /app/./static", dockerfileContent);
await Verify(dockerfileContent);
}

private sealed class MyFilesContainer(string name, string command, string workingDirectory)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
ARG SOURCE_IMAGENAME=source:source-tag

FROM node:22-alpine AS build

WORKDIR /app
COPY . .

RUN npm install

FROM ${SOURCE_IMAGENAME} AS source_stage

FROM node:22-alpine AS runtime

WORKDIR /app
COPY --from=build /app /app
COPY --from=source_stage /app/dist /app/./static


ENV NODE_ENV=production
EXPOSE 3000

USER node

ENTRYPOINT ["node","app.js"]
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ARG EXE_IMAGENAME=exe:deterministc-tag

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder

# Enable bytecode compilation and copy mode for the virtual environment
ENV UV_COMPILE_BYTECODE=1
Expand All @@ -16,9 +18,11 @@ COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev

FROM ${EXE_IMAGENAME} AS exe_stage

FROM python:3.12-slim-bookworm AS app

COPY --from=exe:deterministc-tag /app/dist /app/./static
COPY --from=exe_stage /app/dist /app/./static

# ------------------------------
# 🚀 Runtime stage
Expand Down
18 changes: 17 additions & 1 deletion tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Text;
using System.Text.RegularExpressions;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Testing;
Expand Down Expand Up @@ -819,7 +820,21 @@ public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks()
builder.AddProject<TestProject>("projectName", launchProfileName: null)
.PublishWithContainerFiles(sourceContainer, "./wwwroot");

var dockerfileVerified = false;

using var app = builder.Build();
var fakeContainerRuntime = (FakeContainerRuntime)app.Services.GetRequiredService<IContainerRuntime>();

fakeContainerRuntime.BuildImageAsyncCallback = async (contextPath, dockerfilePath, imageName, options, buildArgs, buildSecrets, stage, cancellationToken) =>
{
// Verify that the Dockerfile contains the expected COPY command
var dockerFileContent = File.ReadAllText(dockerfilePath);
await Verify(dockerFileContent)
.ScrubLinesWithReplace(s => Regex.Replace(s, "FROM projectname:temp-.*", "FROM projectname:temp-"));

dockerfileVerified = true;
};

await app.StartAsync();
await app.WaitForShutdownAsync();

Expand All @@ -829,7 +844,6 @@ public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks()
Assert.Equal("projectName", builtImage.Name);
Assert.False(mockImageBuilder.PushImageCalled);

var fakeContainerRuntime = (FakeContainerRuntime)app.Services.GetRequiredService<IContainerRuntime>();
Assert.True(fakeContainerRuntime.WasTagImageCalled);
var tagCall = Assert.Single(fakeContainerRuntime.TagImageCalls);
Assert.Equal("projectname", tagCall.localImageName);
Expand All @@ -841,6 +855,8 @@ public async Task ProjectResourceWithContainerFilesDestinationAnnotationWorks()
Assert.Empty(buildCall.contextPath);
Assert.NotEmpty(buildCall.dockerfilePath);

Assert.True(dockerfileVerified);

Assert.True(fakeContainerRuntime.WasRemoveImageCalled);
var removeCall = Assert.Single(fakeContainerRuntime.RemoveImageCalls);
Assert.StartsWith("projectname:temp-", removeCall);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public sealed class FakeContainerRuntime(bool shouldFail = false) : IContainerRu
public Dictionary<string, string?>? CapturedBuildArguments { get; private set; }
public Dictionary<string, string?>? CapturedBuildSecrets { get; private set; }
public string? CapturedStage { get; private set; }
public Func<string, string, string, ContainerBuildOptions?, Dictionary<string, string?>, Dictionary<string, string?>, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; }

public Task<bool> CheckIfRunningAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -65,7 +66,7 @@ public Task PushImageAsync(string imageName, CancellationToken cancellationToken
return Task.CompletedTask;
}

public Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
public async Task BuildImageAsync(string contextPath, string dockerfilePath, string imageName, ContainerBuildOptions? options, Dictionary<string, string?> buildArguments, Dictionary<string, string?> buildSecrets, string? stage, CancellationToken cancellationToken)
{
// Capture the arguments for verification in tests
CapturedBuildArguments = buildArguments;
Expand All @@ -79,8 +80,12 @@ public Task BuildImageAsync(string contextPath, string dockerfilePath, string im
throw new InvalidOperationException("Fake container runtime is configured to fail");
}

if (BuildImageAsyncCallback is not null)
{
await BuildImageAsyncCallback(contextPath, dockerfilePath, imageName, options, buildArguments, buildSecrets, stage, cancellationToken);
}

// For testing, we don't need to actually build anything
return Task.CompletedTask;
}

public Task LoginToRegistryAsync(string registryServer, string username, string password, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ARG SOURCE_IMAGENAME=myimage:latest

FROM ${SOURCE_IMAGENAME} AS source_stage

FROM projectname:temp-
COPY --from=source_stage /app/dist /app/./wwwroot

Loading