Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 4 additions & 4 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
var defaultImageTags = context.GetSteps(this, DefaultImageStepTag).Single();
var myBuildStep = context.GetSteps(this, WellKnownPipelineTags.BuildCompute).Single();

var computeResources = context.Model.GetComputeResources()
.Where(r => r.RequiresImageBuildAndPush())
var computeResources = context.Model.Resources
.Where(r => r.RequiresImageBuild())
.ToList();

foreach (var computeResource in computeResources)
Expand Down Expand Up @@ -174,8 +174,8 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet

private static Task DefaultImageTags(PipelineStepContext context)
{
var computeResources = context.Model.GetComputeResources()
.Where(r => r.RequiresImageBuildAndPush())
var computeResources = context.Model.Resources
.Where(r => r.RequiresImageBuild())
.ToList();

var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
Expand Down
13 changes: 12 additions & 1 deletion src/Aspire.Hosting.NodeJs/NodeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,18 @@ public static IResourceBuilder<ViteAppResource> AddViteApp(this IDistributedAppl
.Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.BuildCommandLineArgs)}");
}
});
});

// since Vite apps are typically served via a separate web server, we don't have an entrypoint
if (resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerFileAnnotation))
{
dockerFileAnnotation.HasEntrypoint = false;
}
else
{
throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile.");
}
})
.WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" });
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Hosting.NodeJs/ViteAppResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ namespace Aspire.Hosting.NodeJs;
/// <param name="command">The command to execute the Vite application, such as the script or entry point.</param>
/// <param name="workingDirectory">The working directory from which the Vite application command is executed.</param>
public class ViteAppResource(string name, string command, string workingDirectory)
: NodeAppResource(name, command, workingDirectory);
: NodeAppResource(name, command, workingDirectory), IResourceWithContainerFiles;
111 changes: 111 additions & 0 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.ApplicationModel.Docker;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Python;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -233,6 +236,45 @@ public static IResourceBuilder<PythonAppResource> AddPythonApp(
.WithArgs(scriptArgs);
}

/// <summary>
/// Adds a Uvicorn-based Python application to the distributed application builder with HTTP endpoint configuration.
/// </summary>
/// <remarks>This method configures the application to use Uvicorn as the server and exposes an HTTP
/// endpoint. When publishing, it sets the entry point to use the Uvicorn executable with appropriate arguments for
/// host and port.</remarks>
/// <param name="builder">The distributed application builder to which the Uvicorn application resource will be added.</param>
/// <param name="name">The unique name of the Uvicorn application resource.</param>
/// <param name="appDirectory">The directory containing the Python application files.</param>
/// <param name="app">The ASGI app import path which informs Uvicorn which module and variable to load as your web application.
/// For example, "main:app" means "main.py" file and variable named "app".</param>
/// <returns>A resource builder for further configuration of the Uvicorn Python application resource.</returns>
public static IResourceBuilder<PythonAppResource> AddUvicornApp(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to decide if we make this UvicornAppResource : PythonAppResource

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the advantage to doing this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can build extension methods that target unvicorn configuration. We'll want that but lets file a follow up issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string app)
{
var resourceBuilder = builder.AddPythonExecutable(name, appDirectory, "uvicorn")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamint - is this going to cause a problem with VS Code debugging?

// VS Code debug support - only applicable for Script and Module types
if (entrypointType is EntrypointType.Script or EntrypointType.Module)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we cannot debug any python executable. You'll need to call WithVSCodeDebugSupport and in the launch configuration provide the right entrypoint and then uvicorn as the module

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We an switch to a module instead

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to switch, we just need to indicate to the AppHost to start the right module

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OR, we can switch and make it just work in all cases :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just not sure if there are performance differences between using the uvicorn executable and running as a python module

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.WithHttpEndpoint(env: "PORT")
.WithArgs(c =>
{
c.Args.Add(app);

c.Args.Add("--host");
var endpoint = ((IResourceWithEndpoints)c.Resource).GetEndpoint("http");
if (builder.ExecutionContext.IsPublishMode)
{
c.Args.Add("0.0.0.0");
}
else
{
c.Args.Add(endpoint.EndpointAnnotation.TargetHost);
}

c.Args.Add("--port");
c.Args.Add(endpoint.Property(EndpointProperty.TargetPort));
});

return resourceBuilder;
}

private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
string entrypoint, string virtualEnvironmentPath)
Expand Down Expand Up @@ -465,6 +507,7 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
var runtimeBuilder = context.Builder
.From($"python:{pythonVersion}-slim-bookworm", "app")
.EmptyLine()
.AddStaticFiles(context.Resource, "/app")
.Comment("------------------------------")
.Comment("🚀 Runtime stage")
.Comment("------------------------------")
Expand Down Expand Up @@ -504,9 +547,77 @@ private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
});
});

resourceBuilder.WithPipelineStepFactory(factoryContext =>
{
List<PipelineStep> steps = [];
var buildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-build-compute", factoryContext.Resource);
steps.Add(buildStep);

// ensure any static file references' images are built first
if (factoryContext.Resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesAnnotations))
{
foreach (var containerFile in containerFilesAnnotations)
{
var source = containerFile.Source;
var staticFileBuildStep = CreateBuildImageBuildStep($"{factoryContext.Resource.Name}-{source.Name}-build-compute", source);
buildStep.DependsOn(staticFileBuildStep);
steps.Add(staticFileBuildStep);
}
}

return steps;
});

return resourceBuilder;
}

private static PipelineStep CreateBuildImageBuildStep(string stepName, IResource resource) =>
new()
{
Name = stepName,
Action = async ctx =>
{
var containerImageBuilder = ctx.Services.GetRequiredService<IResourceContainerImageBuilder>();
await containerImageBuilder.BuildImageAsync(
resource,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
},
ctx.CancellationToken).ConfigureAwait(false);
},
Tags = [WellKnownPipelineTags.BuildCompute]
};

private static DockerfileStage AddStaticFiles(this DockerfileStage stage, IResource resource, string rootDestinationPath)
{
if (resource.TryGetAnnotationsOfType<ContainerFilesDestinationAnnotation>(out var containerFilesDestinationAnnotations))
{
foreach (var containerFileDestination in containerFilesDestinationAnnotations)
{
// get image name
if (!containerFileDestination.Source.TryGetContainerImageName(out var imageName))
{
throw new InvalidOperationException("Cannot add container files: Source resource does not have a container image name.");
}

var destinationPath = containerFileDestination.DestinationPath;
if (!destinationPath.StartsWith('/'))
{
destinationPath = $"{rootDestinationPath}/{destinationPath}";
}

foreach (var containerFilesSource in containerFileDestination.Source.Annotations.OfType<ContainerFilesSourceAnnotation>())
{
stage.CopyFrom(imageName, containerFilesSource.SourcePath, destinationPath);
}
}

stage.EmptyLine();
}
return stage;
}

private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
{
ArgumentNullException.ThrowIfNull(scriptArgs);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents an annotation that specifies a source resource and destination path for copying container files.
/// </summary>
/// <remarks>
/// This annotation is typically used in scenarios where assets, such as images or static files,
/// need to be copied from one container image to another during the build process.
///
/// This annotation is applied to the destination resource where the source container's files will be copied to.
/// </remarks>
public sealed class ContainerFilesDestinationAnnotation : IResourceAnnotation
{
/// <summary>
/// Gets the resource that provides access to the container files to be copied.
/// </summary>
public required IResource Source { get; init; }

/// <summary>
/// Gets or sets the file system path where the container files will be copied into the destination.
/// </summary>
public required string DestinationPath { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents an annotation that associates a container file/directory with a resource.
/// </summary>
/// <remarks>
/// This annotation is typically used in scenarios where assets, such as images or static files,
/// need to be copied from one container image to another during the build process.
///
/// This annotation is applied to the source resource that produces the files.
/// </remarks>
public sealed class ContainerFilesSourceAnnotation : IResourceAnnotation
{
/// <summary>
/// Gets the file system path to the source file or directory inside the container.
/// </summary>
public required string SourcePath { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public static IEnumerable<IResource> GetComputeResources(this DistributedApplica
continue;
}

if (r.IsBuildOnlyContainer())
{
continue;
}
Comment on lines +31 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it's in the wrong place or this method has the wrong name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion it isn't. A "build only container" (i.e. one that doesn't have an entrypoint) isn't a "Compute Resource". It can't stand alone. It doesn't have an entrypoint.


yield return r;
}
}
Expand Down
12 changes: 6 additions & 6 deletions src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class DockerfileStage : DockerfileStatement
public DockerfileStage(string? stageName, string imageReference)
{
StageName = stageName;

// Add the FROM statement as the first statement
_statements.Add(new DockerfileFromStatement(imageReference, stageName));
}
Expand Down Expand Up @@ -108,17 +108,17 @@ public DockerfileStage Copy(string source, string destination)
/// <summary>
/// Adds a COPY statement to copy files from another stage.
/// </summary>
/// <param name="stage">The source stage name.</param>
/// <param name="from">The source stage or image name.</param>
/// <param name="source">The source path in the stage.</param>
/// <param name="destination">The destination path.</param>
/// <returns>The current stage.</returns>
[Experimental("ASPIREDOCKERFILEBUILDER001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public DockerfileStage CopyFrom(string stage, string source, string destination)
public DockerfileStage CopyFrom(string from, string source, string destination)
{
ArgumentException.ThrowIfNullOrEmpty(stage);
ArgumentException.ThrowIfNullOrEmpty(from);
ArgumentException.ThrowIfNullOrEmpty(source);
ArgumentException.ThrowIfNullOrEmpty(destination);
_statements.Add(new DockerfileCopyFromStatement(stage, source, destination));
_statements.Add(new DockerfileCopyFromStatement(from, source, destination));
return this;
}

Expand Down Expand Up @@ -284,4 +284,4 @@ public override async Task WriteStatementAsync(StreamWriter writer, Cancellation
await statement.WriteStatementAsync(writer, cancellationToken).ConfigureAwait(false);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ public class DockerfileBuildAnnotation(string contextPath, string dockerfilePath
/// When set, this will be used as the container image tag instead of the value from ContainerImageAnnotation.
/// </summary>
public string? ImageTag { get; set; }

/// <summary>
/// Gets or sets a value indicating whether an entry point is defined in the Dockerfile.
/// </summary>
/// <remarks>
/// Container images without an entry point are not considered compute resources.
/// </remarks>
public bool HasEntrypoint { get; set; } = true;
}
17 changes: 17 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/IResourceWithContainerFiles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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;

namespace Aspire.Hosting;

/// <summary>
/// Represents a resource that contains files that can be copied to other resources.
/// </summary>
/// <remarks>
/// Resources that implement this interface produce container images that include files
/// that can be copied into other resources. For example using Docker's COPY --from feature.
/// </remarks>
public interface IResourceWithContainerFiles : IResource
{
}
21 changes: 20 additions & 1 deletion src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,19 @@ public static int GetReplicaCount(this IResource resource)
}
}

/// <summary>
/// Determines whether the specified resource requires image building.
/// </summary>
/// <remarks>
/// Resources require an image build if they provide their own Dockerfile or are a project.
/// </remarks>
/// <param name="resource">The resource to evaluate for image build requirements.</param>
/// <returns>True if the resource requires image building; otherwise, false.</returns>
public static bool RequiresImageBuild(this IResource resource)
{
return resource is ProjectResource || resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _);
}

/// <summary>
/// Determines whether the specified resource requires image building and pushing.
/// </summary>
Expand All @@ -612,7 +625,13 @@ public static int GetReplicaCount(this IResource resource)
/// <returns>True if the resource requires image building and pushing; otherwise, false.</returns>
public static bool RequiresImageBuildAndPush(this IResource resource)
{
return resource is ProjectResource || resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out _);
return resource.RequiresImageBuild() && !resource.IsBuildOnlyContainer();
}

internal static bool IsBuildOnlyContainer(this IResource resource)
{
return resource.TryGetLastAnnotation<DockerfileBuildAnnotation>(out var dockerfileBuild) &&
!dockerfileBuild.HasEntrypoint;
}

/// <summary>
Expand Down
23 changes: 23 additions & 0 deletions src/Aspire.Hosting/ResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,29 @@ public static IResourceBuilder<T> WithUrlForEndpoint<T>(this IResourceBuilder<T>
return builder;
}

/// <summary>
/// Configures the resource to copy container files from the specified source resource during publishing.
/// </summary>
/// <typeparam name="T">The type of resource being built. Must implement <see cref="IResource"/>.</typeparam>
/// <param name="builder">The resource builder to which container files will be copied to.</param>
/// <param name="source">The resource which contains the container files to be copied.</param>
/// <param name="destinationPath">The destination path within the resource's container where the files will be copied.</param>
public static IResourceBuilder<T> PublishWithContainerFiles<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<IResourceWithContainerFiles> source,
string destinationPath) where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);
ArgumentException.ThrowIfNullOrEmpty(destinationPath);

return builder.WithAnnotation(new ContainerFilesDestinationAnnotation()
{
Source = source.Resource,
DestinationPath = destinationPath
});
}

/// <summary>
/// Excludes a resource from being published to the manifest.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,3 @@ async def health_check():
# Serve static files directly from root, if the "static" directory exists
if os.path.exists("static"):
app.mount("/", fastapi.staticfiles.StaticFiles(directory="static", html=True), name="static")


if __name__ == "__main__":
import uvicorn

port = int(os.environ.get("PORT", 8111))
host = os.environ.get("HOST", "127.0.0.1")
reload = os.environ.get("DEBUG", "False").lower() == "true"

uvicorn.run("app:app", host=host, port=port, reload=reload, log_level="info")
Loading