Skip to content

Commit f5e40da

Browse files
eerhardtCopilot
andauthored
Add support for docker static files (#12265)
* Add support for container files This allows a resource that contains files (for example a Javascript frontend) to embed its files into another app server - for example a fastapi python app backend. Key changes: * Add ContainerFilesSourceAnnotation which goes on the resource that can produce files. A resource with this annotation builds a docker image, but the image doesn't get pushed to a registry. * Add ContainerFilesDestinationAnnotation which goes on the resource that receives the files. Resources that support this COPY the static files from the source resource into their own docker image. * All compute environment resources respect a new HasEntrypoint=false bool to mean that this resource shouldn't be considered a compute resource, but should still build an image. Contributes to #12162 * Add a test and fix up code. * Add tests * Update src/Aspire.Hosting/ApplicationModel/IResourceWithContainerFiles.cs * Update src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs Co-authored-by: Copilot <[email protected]> * Respond to PR feedback * Rename * Only add container files annotation during publish. --------- Co-authored-by: Copilot <[email protected]>
1 parent d9c3266 commit f5e40da

File tree

19 files changed

+464
-63
lines changed

19 files changed

+464
-63
lines changed

src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
159159
var defaultImageTags = context.GetSteps(this, DefaultImageStepTag).Single();
160160
var myBuildStep = context.GetSteps(this, WellKnownPipelineTags.BuildCompute).Single();
161161

162-
var computeResources = context.Model.GetComputeResources()
163-
.Where(r => r.RequiresImageBuildAndPush())
162+
var computeResources = context.Model.Resources
163+
.Where(r => r.RequiresImageBuild())
164164
.ToList();
165165

166166
foreach (var computeResource in computeResources)
@@ -194,8 +194,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
194194

195195
private static Task DefaultImageTags(PipelineStepContext context)
196196
{
197-
var computeResources = context.Model.GetComputeResources()
198-
.Where(r => r.RequiresImageBuildAndPush())
197+
var computeResources = context.Model.Resources
198+
.Where(r => r.RequiresImageBuild())
199199
.ToList();
200200

201201
var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";

src/Aspire.Hosting.NodeJs/NodeExtensions.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,18 @@ public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedAppl
183183
.Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.BuildCommandLineArgs)}");
184184
}
185185
});
186-
});
186+
187+
// since Vite apps are typically served via a separate web server, we don't have an entrypoint
188+
if (resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerFileAnnotation))
189+
{
190+
dockerFileAnnotation.HasEntrypoint = false;
191+
}
192+
else
193+
{
194+
throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile.");
195+
}
196+
})
197+
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" });
187198
}
188199

189200
/// <summary>

src/Aspire.Hosting.NodeJs/ViteAppResource.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ namespace Aspire.Hosting.NodeJs;
1010
/// <param name="command">The command to execute the Vite application, such as the script or entry point.</param>
1111
/// <param name="workingDirectory">The working directory from which the Vite application command is executed.</param>
1212
public class ViteAppResource(string name, string command, string workingDirectory)
13-
: NodeAppResource(name, command, workingDirectory);
13+
: NodeAppResource(name, command, workingDirectory), IResourceWithContainerFiles;

src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
using System.ComponentModel;
55
using System.Runtime.CompilerServices;
66
using Aspire.Hosting.ApplicationModel;
7+
using Aspire.Hosting.ApplicationModel.Docker;
8+
using Aspire.Hosting.Pipelines;
9+
using Aspire.Hosting.Publishing;
710
using Aspire.Hosting.Python;
811
using Microsoft.Extensions.DependencyInjection;
912
using Microsoft.Extensions.Logging;
@@ -233,6 +236,45 @@ public static IResourceBuilder<PythonAppResource> AddPythonApp(
233236
.WithArgs(scriptArgs);
234237
}
235238

239+
/// <summary>
240+
/// Adds a Uvicorn-based Python application to the distributed application builder with HTTP endpoint configuration.
241+
/// </summary>
242+
/// <remarks>This method configures the application to use Uvicorn as the server and exposes an HTTP
243+
/// endpoint. When publishing, it sets the entry point to use the Uvicorn executable with appropriate arguments for
244+
/// host and port.</remarks>
245+
/// <param name="builder">The distributed application builder to which the Uvicorn application resource will be added.</param>
246+
/// <param name="name">The unique name of the Uvicorn application resource.</param>
247+
/// <param name="appDirectory">The directory containing the Python application files.</param>
248+
/// <param name="app">The ASGI app import path which informs Uvicorn which module and variable to load as your web application.
249+
/// For example, "main:app" means "main.py" file and variable named "app".</param>
250+
/// <returns>A resource builder for further configuration of the Uvicorn Python application resource.</returns>
251+
public static IResourceBuilder<PythonAppResource> AddUvicornApp(
252+
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string app)
253+
{
254+
var resourceBuilder = builder.AddPythonExecutable(name, appDirectory, "uvicorn")
255+
.WithHttpEndpoint(env: "PORT")
256+
.WithArgs(c =>
257+
{
258+
c.Args.Add(app);
259+
260+
c.Args.Add("--host");
261+
var endpoint = ((IResourceWithEndpoints)c.Resource).GetEndpoint("http");
262+
if (builder.ExecutionContext.IsPublishMode)
263+
{
264+
c.Args.Add("0.0.0.0");
265+
}
266+
else
267+
{
268+
c.Args.Add(endpoint.EndpointAnnotation.TargetHost);
269+
}
270+
271+
c.Args.Add("--port");
272+
c.Args.Add(endpoint.Property(EndpointProperty.TargetPort));
273+
});
274+
275+
return resourceBuilder;
276+
}
277+
236278
private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
237279
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
238280
string entrypoint, string virtualEnvironmentPath)
@@ -465,6 +507,7 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
465507
var runtimeBuilder = context.Builder
466508
.From($"python:{pythonVersion}-slim-bookworm", "app")
467509
.EmptyLine()
510+
.AddContainerFiles(context.Resource, "/app")
468511
.Comment("------------------------------")
469512
.Comment("🚀 Runtime stage")
470513
.Comment("------------------------------")
@@ -504,9 +547,77 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
504547
});
505548
});
506549

550+
resourceBuilder.WithPipelineStepFactory(factoryContext =>
551+
{
552+
List<PipelineStep> steps = [];
553+
var buildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource);
554+
steps.Add(buildStep);
555+
556+
// ensure any static file references' images are built first
557+
if (factoryContext.Resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
558+
{
559+
foreach (var containerFile in containerFilesAnnotations)
560+
{
561+
var source = containerFile.Source;
562+
var staticFileBuildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source);
563+
buildStep.DependsOn(staticFileBuildStep);
564+
steps.Add(staticFileBuildStep);
565+
}
566+
}
567+
568+
return steps;
569+
});
570+
507571
return resourceBuilder;
508572
}
509573

574+
private static PipelineStep CreateBuildImageBuildStep(string stepName, IResource resource) =>
575+
new()
576+
{
577+
Name = stepName,
578+
Action = async ctx =>
579+
{
580+
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
581+
await containerImageBuilder.BuildImageAsync(
582+
resource,
583+
new ContainerBuildOptions
584+
{
585+
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
586+
},
587+
ctx.CancellationToken).ConfigureAwait(false);
588+
},
589+
Tags = [WellKnownPipelineTags.BuildCompute]
590+
};
591+
592+
private static DockerfileStage AddContainerFiles(this DockerfileStage stage, IResource resource, string rootDestinationPath)
593+
{
594+
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesDestinationAnnotations))
595+
{
596+
foreach (var containerFileDestination in containerFilesDestinationAnnotations)
597+
{
598+
// get image name
599+
if (!containerFileDestination.Source.TryGetContainerImageName(out var imageName))
600+
{
601+
throw new InvalidOperationException("Cannot add container files: Source resource does not have a container image name.");
602+
}
603+
604+
var destinationPath = containerFileDestination.DestinationPath;
605+
if (!destinationPath.StartsWith('/'))
606+
{
607+
destinationPath = $"{rootDestinationPath}/{destinationPath}";
608+
}
609+
610+
foreach (var containerFilesSource in containerFileDestination.Source.Annotations.OfType<ContainerFilesSourceAnnotation>())
611+
{
612+
stage.CopyFrom(imageName, containerFilesSource.SourcePath, destinationPath);
613+
}
614+
}
615+
616+
stage.EmptyLine();
617+
}
618+
return stage;
619+
}
620+
510621
private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
511622
{
512623
ArgumentNullException.ThrowIfNull(scriptArgs);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Hosting.ApplicationModel;
5+
6+
/// <summary>
7+
/// Represents an annotation that specifies a source resource and destination path for copying container files.
8+
/// </summary>
9+
/// <remarks>
10+
/// This annotation is typically used in scenarios where assets, such as images or static files,
11+
/// need to be copied from one container image to another during the build process.
12+
///
13+
/// This annotation is applied to the destination resource where the source container's files will be copied to.
14+
/// </remarks>
15+
public sealed class ContainerFilesDestinationAnnotation : IResourceAnnotation
16+
{
17+
/// <summary>
18+
/// Gets the resource that provides access to the container files to be copied.
19+
/// </summary>
20+
public required IResource Source { get; init; }
21+
22+
/// <summary>
23+
/// Gets or sets the file system path where the container files will be copied into the destination.
24+
/// </summary>
25+
public required string DestinationPath { get; init; }
26+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Hosting.ApplicationModel;
5+
6+
/// <summary>
7+
/// Represents an annotation that associates a container file/directory with a resource.
8+
/// </summary>
9+
/// <remarks>
10+
/// This annotation is typically used in scenarios where assets, such as images or static files,
11+
/// need to be copied from one container image to another during the build process.
12+
///
13+
/// This annotation is applied to the source resource that produces the files.
14+
/// </remarks>
15+
public sealed class ContainerFilesSourceAnnotation : IResourceAnnotation
16+
{
17+
/// <summary>
18+
/// Gets the file system path to the source file or directory inside the container.
19+
/// </summary>
20+
public required string SourcePath { get; init; }
21+
}

src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ public static IEnumerable<IResource> GetComputeResources(this DistributedApplica
2828
continue;
2929
}
3030

31+
if (r.IsBuildOnlyContainer())
32+
{
33+
continue;
34+
}
35+
3136
yield return r;
3237
}
3338
}

src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class DockerfileStage : DockerfileStatement
2121
public DockerfileStage(string? stageName, string imageReference)
2222
{
2323
StageName = stageName;
24-
24+
2525
// Add the FROM statement as the first statement
2626
_statements.Add(new DockerfileFromStatement(imageReference, stageName));
2727
}
@@ -108,17 +108,17 @@ public DockerfileStage Copy(string source, string destination)
108108
/// <summary>
109109
/// Adds a COPY statement to copy files from another stage.
110110
/// </summary>
111-
/// <param name="stage">The source stage name.</param>
111+
/// <param name="from">The source stage or image name.</param>
112112
/// <param name="source">The source path in the stage.</param>
113113
/// <param name="destination">The destination path.</param>
114114
/// <returns>The current stage.</returns>
115115
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
116-
public DockerfileStage CopyFrom(string stage, string source, string destination)
116+
public DockerfileStage CopyFrom(string from, string source, string destination)
117117
{
118-
ArgumentException.ThrowIfNullOrEmpty(stage);
118+
ArgumentException.ThrowIfNullOrEmpty(from);
119119
ArgumentException.ThrowIfNullOrEmpty(source);
120120
ArgumentException.ThrowIfNullOrEmpty(destination);
121-
_statements.Add(new DockerfileCopyFromStatement(stage, source, destination));
121+
_statements.Add(new DockerfileCopyFromStatement(from, source, destination));
122122
return this;
123123
}
124124

@@ -284,4 +284,4 @@ public override async Task WriteStatementAsync(StreamWriter writer, Cancellation
284284
await statement.WriteStatementAsync(writer, cancellationToken).ConfigureAwait(false);
285285
}
286286
}
287-
}
287+
}

src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,12 @@ public class DockerfileBuildAnnotation(string contextPath, string dockerfilePath
5454
/// When set, this will be used as the container image tag instead of the value from ContainerImageAnnotation.
5555
/// </summary>
5656
public string? ImageTag { get; set; }
57+
58+
/// <summary>
59+
/// Gets or sets a value indicating whether an entry point is defined in the Dockerfile.
60+
/// </summary>
61+
/// <remarks>
62+
/// Container images without an entry point are not considered compute resources.
63+
/// </remarks>
64+
public bool HasEntrypoint { get; set; } = true;
5765
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
6+
namespace Aspire.Hosting;
7+
8+
/// <summary>
9+
/// Represents a resource that contains files that can be copied to other resources.
10+
/// </summary>
11+
/// <remarks>
12+
/// Resources that implement this interface produce container images that include files
13+
/// that can be copied into other resources. For example using Docker's COPY --from feature.
14+
/// </remarks>
15+
public interface IResourceWithContainerFiles : IResource
16+
{
17+
}

0 commit comments

Comments
 (0)