Skip to content

Commit 6e4a15d

Browse files
committed
Add support for docker static files
This allows a resource that contains static files (for example a Javascript frontend) to embed its static files into another app server - for example a fastapi python app backend. Key changes: * Add StaticDockerFilesAnnotation which goes on the resource that can produce static files. A resource with this annotation builds a docker image, but the image doesn't get pushed to a registry. * Add StaticDockerFileDestinationAnnotation which goes on the resource that receives the static files. Resources that support this COPY the static files from the source resource into their own docker image. * AzureEnvironmentResource recognizes resources that have their own "build compute" step, and delegate to those steps for building their images. * All compute environment resources respect the new StaticDockerFilesAnnotation to mean that this resource shouldn't be considered a compute resource. Contributes to #12162
1 parent 36a414e commit 6e4a15d

File tree

15 files changed

+327
-109
lines changed

15 files changed

+327
-109
lines changed

Aspire.slnx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@
106106
<File Path="playground/README.md" />
107107
<Project Path="playground/Playground.ServiceDefaults/Playground.ServiceDefaults.csproj" />
108108
</Folder>
109+
<Folder Name="/playground/AspireWithPython/">
110+
<Project Path="playground/AspireWithPython/AspireWithPython.AppHost/AspireWithPython.AppHost.csproj" />
111+
<Project Path="playground/AspireWithPython/AspireWithPython.ServiceDefaults/AspireWithPython.ServiceDefaults.csproj" />
112+
</Folder>
109113
<Folder Name="/playground/AzureAIFoundryEndToEnd/">
110114
<Project Path="playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.AppHost/AzureAIFoundryEndToEnd.AppHost.csproj" />
111115
<Project Path="playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj" />
@@ -176,8 +180,8 @@
176180
<Project Path="playground/deployers/Deployers.AppHost/Deployers.AppHost.csproj" />
177181
</Folder>
178182
<Folder Name="/playground/DevTunnels/">
179-
<Project Path="playground/DevTunnels/DevTunnels.AppHost/DevTunnels.AppHost.csproj" />
180183
<Project Path="playground/DevTunnels/DevTunnels.ApiService/DevTunnels.ApiService.csproj" />
184+
<Project Path="playground/DevTunnels/DevTunnels.AppHost/DevTunnels.AppHost.csproj" />
181185
<Project Path="playground/DevTunnels/DevTunnels.WebFrontEnd/DevTunnels.WebFrontEnd.csproj" />
182186
</Folder>
183187
<Folder Name="/playground/dockerfile/">
@@ -234,8 +238,8 @@
234238
</Folder>
235239
<Folder Name="/playground/node/">
236240
<Project Path="playground/AspireWithNode/AspireWithNode.AppHost/AspireWithNode.AppHost.csproj" />
237-
<Project Path="playground/AspireWithNode/AspireWithNode.ServiceDefaults/AspireWithNode.ServiceDefaults.csproj" />
238241
<Project Path="playground/AspireWithNode/AspireWithNode.AspNetCoreApi/AspireWithNode.AspNetCoreApi.csproj" />
242+
<Project Path="playground/AspireWithNode/AspireWithNode.ServiceDefaults/AspireWithNode.ServiceDefaults.csproj" />
239243
</Folder>
240244
<Folder Name="/playground/OpenAIEndToEnd/">
241245
<Project Path="playground/OpenAIEndToEnd/OpenAIEndToEnd.AppHost/OpenAIEndToEnd.AppHost.csproj" />
@@ -278,10 +282,6 @@
278282
<Folder Name="/playground/python/">
279283
<Project Path="playground/python/Python.AppHost/Python.AppHost.csproj" />
280284
</Folder>
281-
<Folder Name="/playground/pythonweb/">
282-
<Project Path="playground/AspireWithPython/AspireWithPython.AppHost/AspireWithPython.AppHost.csproj" />
283-
<Project Path="playground/AspireWithPython/AspireWithPython.ServiceDefaults/AspireWithPython.ServiceDefaults.csproj" />
284-
</Folder>
285285
<Folder Name="/playground/qdrant/">
286286
<Project Path="playground/Qdrant/Qdrant.ApiService/Qdrant.ApiService.csproj" />
287287
<Project Path="playground/Qdrant/Qdrant.AppHost/Qdrant.AppHost.csproj" />

src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,15 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
8383
};
8484
provisionStep.DependsOn(createContextStep);
8585

86-
var buildStep = new PipelineStep
86+
var addImageTagsStep = new PipelineStep
8787
{
88-
Name = WellKnownPipelineSteps.BuildCompute,
89-
Action = ctx => BuildContainerImagesAsync(ctx)
88+
Name = "default-image-tags",
89+
Action = ctx => DefaultImageTags(ctx)
9090
};
9191

92+
var buildStep = CreateBuildComputeStep(factoryContext);
93+
buildStep.DependsOn(addImageTagsStep);
94+
9295
var pushStep = new PipelineStep
9396
{
9497
Name = "push-container-images",
@@ -112,7 +115,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
112115
};
113116
printDashboardUrlStep.DependsOn(deployStep);
114117

115-
return [validateStep, createContextStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
118+
return [validateStep, createContextStep, provisionStep, addImageTagsStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
116119
}));
117120

118121
Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
@@ -122,6 +125,58 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
122125
PrincipalId = principalId;
123126
}
124127

128+
private static Task DefaultImageTags(PipelineStepContext context)
129+
{
130+
var computeResources = context.Model.Resources
131+
.Where(r => r.RequiresImageBuild())
132+
.ToList();
133+
134+
var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
135+
foreach (var resource in computeResources)
136+
{
137+
if (resource.TryGetLastAnnotation<DeploymentImageTagCallbackAnnotation>(out _))
138+
{
139+
continue;
140+
}
141+
resource.Annotations.Add(
142+
new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
143+
}
144+
145+
return Task.CompletedTask;
146+
}
147+
148+
private static PipelineStep CreateBuildComputeStep(PipelineStepFactoryContext factoryContext)
149+
{
150+
var computeResourcesWithoutBuildSteps = new List<IResource>();
151+
var buildStep = new PipelineStep
152+
{
153+
Name = WellKnownPipelineSteps.BuildCompute,
154+
Action = ctx => BuildContainerImagesAsync(ctx, computeResourcesWithoutBuildSteps)
155+
};
156+
157+
var computeResources = factoryContext.PipelineContext.Model.GetComputeResources()
158+
.Where(r => r.RequiresImageBuild())
159+
.ToList();
160+
161+
foreach (var computeResource in computeResources)
162+
{
163+
var buildComputeStepName = computeResource.TryGetLastAnnotation<PipelineBuildComputeStepAnnotation>(out var buildStepAnnotation)
164+
? buildStepAnnotation.StepName
165+
: null;
166+
if (buildComputeStepName is not null)
167+
{
168+
buildStep.DependsOn(buildComputeStepName);
169+
}
170+
else
171+
{
172+
// if the compute resource doesn't have a its own build step, add it to the default build step
173+
computeResourcesWithoutBuildSteps.Add(computeResource);
174+
}
175+
}
176+
177+
return buildStep;
178+
}
179+
125180
private Task PublishAsync(PublishingContext context)
126181
{
127182
var azureProvisioningOptions = context.Services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
@@ -268,29 +323,14 @@ await deploymentStateManager.SaveStateAsync(
268323
}
269324
}
270325

271-
private static async Task BuildContainerImagesAsync(PipelineStepContext context)
326+
private static async Task BuildContainerImagesAsync(PipelineStepContext context, List<IResource> computeResources)
272327
{
273-
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();
274-
275-
var computeResources = context.Model.GetComputeResources()
276-
.Where(r => r.RequiresImageBuildAndPush())
277-
.ToList();
278-
279328
if (!computeResources.Any())
280329
{
281330
return;
282331
}
283332

284-
var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
285-
foreach (var resource in computeResources)
286-
{
287-
if (resource.TryGetLastAnnotation<DeploymentImageTagCallbackAnnotation>(out _))
288-
{
289-
continue;
290-
}
291-
resource.Annotations.Add(
292-
new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
293-
}
333+
var containerImageBuilder = context.Services.GetRequiredService<IResourceContainerImageBuilder>();
294334

295335
await containerImageBuilder.BuildImagesAsync(
296336
computeResources,

src/Aspire.Hosting.NodeJs/NodeAppResource.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ namespace Aspire.Hosting;
1212
/// <param name="command">The command to execute.</param>
1313
/// <param name="workingDirectory">The working directory to use for the command. If null, the working directory of the current process is used.</param>
1414
public class NodeAppResource(string name, string command, string workingDirectory)
15-
: ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery;
15+
: ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IResourceWithStaticDockerFiles;

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,44 @@ 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="moduleName"></param>
249+
/// <returns>A resource builder for further configuration of the Uvicorn Python application resource.</returns>
250+
public static IResourceBuilder<PythonAppResource> AddUvicornApp(
251+
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string moduleName)
252+
{
253+
var resourceBuilder = builder.AddPythonExecutable(name, appDirectory, "uvicorn")
254+
.WithHttpEndpoint(env: "PORT")
255+
.WithArgs(c =>
256+
{
257+
c.Args.Add(moduleName);
258+
259+
c.Args.Add("--host");
260+
var endpoint = ((IResourceWithEndpoints)c.Resource).GetEndpoint("http");
261+
if (builder.ExecutionContext.IsPublishMode)
262+
{
263+
c.Args.Add("0.0.0.0");
264+
}
265+
else
266+
{
267+
c.Args.Add(endpoint.EndpointAnnotation.TargetHost);
268+
}
269+
270+
c.Args.Add("--port");
271+
c.Args.Add(endpoint.Property(EndpointProperty.TargetPort));
272+
});
273+
274+
return resourceBuilder;
275+
}
276+
236277
private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
237278
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
238279
string entrypoint, string virtualEnvironmentPath)
@@ -436,6 +477,7 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
436477
var runtimeBuilder = context.Builder
437478
.From($"python:{pythonVersion}-slim-bookworm", "app")
438479
.EmptyLine()
480+
.AddStaticFiles(context.Resource, "/app")
439481
.Comment("------------------------------")
440482
.Comment("🚀 Runtime stage")
441483
.Comment("------------------------------")
@@ -475,9 +517,78 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
475517
});
476518
});
477519

520+
resourceBuilder.WithPipelineStepFactory(factoryContext =>
521+
{
522+
var buildStep = new PipelineStep
523+
{
524+
Name = $"{factoryContext.Resource.Name}-build-compute",
525+
Action = async ctx =>
526+
{
527+
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
528+
529+
// ensure any static file references' images are built first
530+
if (factoryContext.Resource.TryGetAnnotationsOfType<StaticDockerFileDestinationAnnotation>(out var staticFileAnnotations))
531+
{
532+
foreach (var staticFileAnnotation in staticFileAnnotations)
533+
{
534+
await containerImageBuilder.BuildImageAsync(
535+
staticFileAnnotation.Source,
536+
new ContainerBuildOptions
537+
{
538+
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
539+
},
540+
ctx.CancellationToken).ConfigureAwait(false);
541+
}
542+
}
543+
544+
await containerImageBuilder.BuildImageAsync(
545+
factoryContext.Resource,
546+
new ContainerBuildOptions
547+
{
548+
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
549+
},
550+
ctx.CancellationToken).ConfigureAwait(false);
551+
}
552+
};
553+
554+
return buildStep;
555+
})
556+
.WithAnnotation(new PipelineBuildComputeStepAnnotation() { StepName = $"{resource.Name}-build-compute" });
557+
478558
return resourceBuilder;
479559
}
480560

561+
private static DockerfileStage AddStaticFiles(this DockerfileStage stage, IResource resource, string rootDestinationPath)
562+
{
563+
if (resource.TryGetAnnotationsOfType<StaticDockerFileDestinationAnnotation>(out var staticFileDestinationAnnotations))
564+
{
565+
foreach (var staticFileDestAnnotation in staticFileDestinationAnnotations)
566+
{
567+
// get image name
568+
if (!staticFileDestAnnotation.Source.TryGetContainerImageName(out var imageName))
569+
{
570+
throw new InvalidOperationException("Cannot add static files: Source resource does not have a container image name.");
571+
}
572+
573+
// get the source path
574+
if (!staticFileDestAnnotation.Source.TryGetLastAnnotation<StaticDockerFilesAnnotation>(out var staticFileAnnotation))
575+
{
576+
throw new InvalidOperationException("Cannot add static files: Source resource does not have a static file source path annotation.");
577+
}
578+
579+
var destinationPath = staticFileDestAnnotation.DestinationPath;
580+
if (!destinationPath.StartsWith('/'))
581+
{
582+
destinationPath = $"{rootDestinationPath}/{destinationPath}";
583+
}
584+
stage.CopyFrom(imageName, staticFileAnnotation.SourcePath, destinationPath);
585+
}
586+
587+
stage.EmptyLine();
588+
}
589+
return stage;
590+
}
591+
481592
private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
482593
{
483594
ArgumentNullException.ThrowIfNull(scriptArgs);

src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs

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

31+
// Resources that have static files should not be considered compute resources
32+
if (r.HasAnnotationOfType<StaticDockerFilesAnnotation>())
33+
{
34+
continue;
35+
}
36+
3137
yield return r;
3238
}
3339
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
///
10+
/// </summary>
11+
public interface IResourceWithStaticDockerFiles : IResource
12+
{
13+
}

src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,19 @@ public static int GetReplicaCount(this IResource resource)
601601
}
602602
}
603603

604+
/// <summary>
605+
/// Determines whether the specified resource requires image building.
606+
/// </summary>
607+
/// <remarks>
608+
/// Resources require an image build if they provide their own Dockerfile or are a project.
609+
/// </remarks>
610+
/// <param name="resource">The resource to evaluate for image build requirements.</param>
611+
/// <returns>True if the resource requires image building; otherwise, false.</returns>
612+
public static bool RequiresImageBuild(this IResource resource)
613+
{
614+
return resource is ProjectResource || resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _);
615+
}
616+
604617
/// <summary>
605618
/// Determines whether the specified resource requires image building and pushing.
606619
/// </summary>
@@ -612,7 +625,7 @@ public static int GetReplicaCount(this IResource resource)
612625
/// <returns>True if the resource requires image building and pushing; otherwise, false.</returns>
613626
public static bool RequiresImageBuildAndPush(this IResource resource)
614627
{
615-
return resource is ProjectResource || resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _);
628+
return resource.RequiresImageBuild() && !resource.HasAnnotationOfType<StaticDockerFilesAnnotation>();
616629
}
617630

618631
/// <summary>

0 commit comments

Comments
 (0)