diff --git a/Aspire.slnx b/Aspire.slnx
index 2dab93a8f6a..adc5e61025b 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -111,6 +111,10 @@
+
+
+
+
@@ -278,10 +282,6 @@
-
-
-
-
diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs
index ce380e237df..7c78e6c377b 100644
--- a/playground/pipelines/Pipelines.AppHost/AppHost.cs
+++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs
@@ -225,7 +225,7 @@ await assignRoleTask.CompleteAsync(
context.CancellationToken).ConfigureAwait(false);
}
}
-}, requiredBy: "upload-bind-mounts", dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure);
+}, requiredBy: "upload-bind-mounts", dependsOn: WellKnownPipelineTags.ProvisionInfrastructure);
builder.Pipeline.AddStep("upload-bind-mounts", async (deployingContext) =>
{
@@ -324,7 +324,7 @@ await uploadTask.CompleteAsync(
totalUploads += fileCount;
}
}
-}, requiredBy: WellKnownPipelineSteps.DeployCompute, dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure);
+}, requiredBy: WellKnownPipelineTags.DeployCompute, dependsOn: WellKnownPipelineTags.ProvisionInfrastructure);
builder.AddProject("api-service")
.WithComputeEnvironment(aasEnv)
diff --git a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs
index 48889313fdb..63c096cf6b3 100644
--- a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs
+++ b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs
@@ -50,7 +50,7 @@ await DeployProjectToAppServiceAsync(
}
}
}
- }, dependsOn: WellKnownPipelineSteps.DeployCompute);
+ }, dependsOn: WellKnownPipelineTags.DeployCompute);
return pipeline;
}
diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
index 5dcc3cc58cf..9f6d418f8ed 100644
--- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
+++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
@@ -31,6 +31,8 @@ namespace Aspire.Hosting.Azure;
[Experimental("ASPIREAZURE001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
public sealed class AzureEnvironmentResource : Resource
{
+ private const string DefaultImageStepTag = "default-image-tags";
+
///
/// Gets or sets the Azure location that the resources will be deployed to.
///
@@ -46,6 +48,8 @@ public sealed class AzureEnvironmentResource : Resource
///
public ParameterResource PrincipalId { get; set; }
+ private readonly List _computeResourcesToBuild = [];
+
///
/// Initializes a new instance of the class.
///
@@ -82,16 +86,25 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
var provisionStep = new PipelineStep
{
- Name = WellKnownPipelineSteps.ProvisionInfrastructure,
+ Name = WellKnownPipelineTags.ProvisionInfrastructure,
Action = ctx => ProvisionAzureBicepResourcesAsync(ctx, provisioningContext!)
};
provisionStep.DependsOn(createContextStep);
+ var addImageTagsStep = new PipelineStep
+ {
+ Name = DefaultImageStepTag,
+ Action = ctx => DefaultImageTags(ctx),
+ Tags = [DefaultImageStepTag],
+ };
+
var buildStep = new PipelineStep
{
- Name = WellKnownPipelineSteps.BuildCompute,
- Action = ctx => BuildContainerImagesAsync(ctx)
+ Name = WellKnownPipelineTags.BuildCompute,
+ Action = ctx => BuildContainerImagesAsync(ctx),
+ Tags = [WellKnownPipelineTags.BuildCompute],
};
+ buildStep.DependsOn(addImageTagsStep);
var pushStep = new PipelineStep
{
@@ -103,7 +116,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
var deployStep = new PipelineStep
{
- Name = WellKnownPipelineSteps.DeployCompute,
+ Name = WellKnownPipelineTags.DeployCompute,
Action = ctx => DeployComputeResourcesAsync(ctx, provisioningContext!)
};
deployStep.DependsOn(pushStep);
@@ -116,7 +129,38 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
};
printDashboardUrlStep.DependsOn(deployStep);
- return [validateStep, createContextStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
+ return [validateStep, createContextStep, provisionStep, addImageTagsStep, buildStep, pushStep, deployStep, printDashboardUrlStep];
+ }));
+
+ Annotations.Add(new PipelineConfigurationAnnotation(context =>
+ {
+ var defaultImageTags = context.FindStepsByTagAndResource(DefaultImageStepTag, this).Single();
+ var myBuildStep = context.FindStepsByTagAndResource(WellKnownPipelineTags.BuildCompute, this).Single();
+
+ var computeResources = context.ApplicationModel.Resources
+ .Where(r => r.RequiresImageBuild())
+ .ToList();
+
+ foreach (var computeResource in computeResources)
+ {
+ var computeResourceBuildSteps = context.FindStepsByTagAndResource(WellKnownPipelineTags.BuildCompute, computeResource);
+ if (computeResourceBuildSteps.Any())
+ {
+ // add the appropriate dependencies to the compute resource build steps
+ foreach (var computeBuildStep in computeResourceBuildSteps)
+ {
+ computeBuildStep.DependsOn(defaultImageTags);
+ myBuildStep.DependsOn(computeBuildStep);
+ }
+ }
+ else
+ {
+ // No build step exists for this compute resource, so we add it to the main build step
+ _computeResourcesToBuild.Add(computeResource);
+ }
+ }
+
+ return Task.CompletedTask;
}));
Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
@@ -126,6 +170,26 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet
PrincipalId = principalId;
}
+ private static Task DefaultImageTags(PipelineStepContext context)
+ {
+ var computeResources = context.Model.Resources
+ .Where(r => r.RequiresImageBuild())
+ .ToList();
+
+ var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
+ foreach (var resource in computeResources)
+ {
+ if (resource.TryGetLastAnnotation(out _))
+ {
+ continue;
+ }
+ resource.Annotations.Add(
+ new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
+ }
+
+ return Task.CompletedTask;
+ }
+
private Task PublishAsync(PublishingContext context)
{
var azureProvisioningOptions = context.Services.GetRequiredService>();
@@ -240,32 +304,17 @@ await resourceTask.CompleteAsync(
await Task.WhenAll(provisioningTasks).ConfigureAwait(false);
}
- private static async Task BuildContainerImagesAsync(PipelineStepContext context)
+ private async Task BuildContainerImagesAsync(PipelineStepContext context)
{
- var containerImageBuilder = context.Services.GetRequiredService();
-
- var computeResources = context.Model.GetComputeResources()
- .Where(r => r.RequiresImageBuildAndPush())
- .ToList();
-
- if (!computeResources.Any())
+ if (!_computeResourcesToBuild.Any())
{
return;
}
- var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}";
- foreach (var resource in computeResources)
- {
- if (resource.TryGetLastAnnotation(out _))
- {
- continue;
- }
- resource.Annotations.Add(
- new DeploymentImageTagCallbackAnnotation(_ => deploymentTag));
- }
+ var containerImageBuilder = context.Services.GetRequiredService();
await containerImageBuilder.BuildImagesAsync(
- computeResources,
+ _computeResourcesToBuild,
new ContainerBuildOptions
{
TargetPlatform = ContainerTargetPlatform.LinuxAmd64
diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
index dc133ade377..76d3915ca31 100644
--- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
+++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
@@ -183,7 +183,13 @@ public static IResourceBuilder 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 set an entrypoint
+ var dockerFileAnnotation = resource.Annotations.OfType().LastOrDefault()
+ ?? throw new InvalidOperationException("DockerfileBuildAnnotation should after calling PublishAsDockerFile.");
+ dockerFileAnnotation.HasEntrypoint = false;
+ })
+ .WithAnnotation(new StaticDockerFilesAnnotation() { SourcePath = "/app/dist" });
}
///
diff --git a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs
index 6ca2b1690dc..d8b2030a745 100644
--- a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs
+++ b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs
@@ -10,4 +10,4 @@ namespace Aspire.Hosting.NodeJs;
/// The command to execute the Vite application, such as the script or entry point.
/// The working directory from which the Vite application command is executed.
public class ViteAppResource(string name, string command, string workingDirectory)
- : NodeAppResource(name, command, workingDirectory);
+ : NodeAppResource(name, command, workingDirectory), IResourceWithStaticDockerFiles;
diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
index ddfa95b6a5a..4be39fa8cbf 100644
--- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
@@ -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;
@@ -233,6 +236,44 @@ public static IResourceBuilder AddPythonApp(
.WithArgs(scriptArgs);
}
+ ///
+ /// Adds a Uvicorn-based Python application to the distributed application builder with HTTP endpoint configuration.
+ ///
+ /// 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.
+ /// The distributed application builder to which the Uvicorn application resource will be added.
+ /// The unique name of the Uvicorn application resource.
+ /// The directory containing the Python application files.
+ ///
+ /// A resource builder for further configuration of the Uvicorn Python application resource.
+ public static IResourceBuilder AddUvicornApp(
+ this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string moduleName)
+ {
+ var resourceBuilder = builder.AddPythonExecutable(name, appDirectory, "uvicorn")
+ .WithHttpEndpoint(env: "PORT")
+ .WithArgs(c =>
+ {
+ c.Args.Add(moduleName);
+
+ 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 AddPythonAppCore(
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
string entrypoint, string virtualEnvironmentPath)
@@ -465,6 +506,7 @@ private static IResourceBuilder AddPythonAppCore(
var runtimeBuilder = context.Builder
.From($"python:{pythonVersion}-slim-bookworm", "app")
.EmptyLine()
+ .AddStaticFiles(context.Resource, "/app")
.Comment("------------------------------")
.Comment("🚀 Runtime stage")
.Comment("------------------------------")
@@ -504,9 +546,79 @@ private static IResourceBuilder AddPythonAppCore(
});
});
+ resourceBuilder.WithPipelineStepFactory(factoryContext =>
+ {
+ var buildStep = new PipelineStep
+ {
+ Name = $"{factoryContext.Resource.Name}-build-compute",
+ Action = async ctx =>
+ {
+ var containerImageBuilder = ctx.Services.GetRequiredService();
+
+ // ensure any static file references' images are built first
+ if (factoryContext.Resource.TryGetAnnotationsOfType(out var staticFileAnnotations))
+ {
+ foreach (var staticFileAnnotation in staticFileAnnotations)
+ {
+ await containerImageBuilder.BuildImageAsync(
+ staticFileAnnotation.Source,
+ new ContainerBuildOptions
+ {
+ TargetPlatform = ContainerTargetPlatform.LinuxAmd64
+ },
+ ctx.CancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ await containerImageBuilder.BuildImageAsync(
+ factoryContext.Resource,
+ new ContainerBuildOptions
+ {
+ TargetPlatform = ContainerTargetPlatform.LinuxAmd64
+ },
+ ctx.CancellationToken).ConfigureAwait(false);
+ },
+ Tags = [WellKnownPipelineTags.BuildCompute]
+ };
+
+ return buildStep;
+ })
+ .WithAnnotation(new PipelineBuildComputeStepAnnotation() { StepName = $"{resource.Name}-build-compute" });
+
return resourceBuilder;
}
+ private static DockerfileStage AddStaticFiles(this DockerfileStage stage, IResource resource, string rootDestinationPath)
+ {
+ if (resource.TryGetAnnotationsOfType(out var staticFileDestinationAnnotations))
+ {
+ foreach (var staticFileDestAnnotation in staticFileDestinationAnnotations)
+ {
+ // get image name
+ if (!staticFileDestAnnotation.Source.TryGetContainerImageName(out var imageName))
+ {
+ throw new InvalidOperationException("Cannot add static files: Source resource does not have a container image name.");
+ }
+
+ // get the source path
+ if (!staticFileDestAnnotation.Source.TryGetLastAnnotation(out var staticFileAnnotation))
+ {
+ throw new InvalidOperationException("Cannot add static files: Source resource does not have a static file source path annotation.");
+ }
+
+ var destinationPath = staticFileDestAnnotation.DestinationPath;
+ if (!destinationPath.StartsWith('/'))
+ {
+ destinationPath = $"{rootDestinationPath}/{destinationPath}";
+ }
+ stage.CopyFrom(imageName, staticFileAnnotation.SourcePath, destinationPath);
+ }
+
+ stage.EmptyLine();
+ }
+ return stage;
+ }
+
private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
{
ArgumentNullException.ThrowIfNull(scriptArgs);
diff --git a/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs
index 49eab79d372..fa57a0e11cd 100644
--- a/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DistributedApplicationModelExtensions.cs
@@ -28,6 +28,11 @@ public static IEnumerable GetComputeResources(this DistributedApplica
continue;
}
+ if (r.IsBuildOnlyContainer())
+ {
+ continue;
+ }
+
yield return r;
}
}
diff --git a/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs b/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs
index c802ab90de6..5f3127f8756 100644
--- a/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs
+++ b/src/Aspire.Hosting/ApplicationModel/Docker/DockerfileStage.cs
@@ -108,17 +108,17 @@ public DockerfileStage Copy(string source, string destination)
///
/// Adds a COPY statement to copy files from another stage.
///
- /// The source stage name.
+ /// The source stage or image name.
/// The source path in the stage.
/// The destination path.
/// The current stage.
[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;
}
@@ -284,4 +284,4 @@ public override async Task WriteStatementAsync(StreamWriter writer, Cancellation
await statement.WriteStatementAsync(writer, cancellationToken).ConfigureAwait(false);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs
index 7dec7a40c55..9885a019781 100644
--- a/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DockerfileBuildAnnotation.cs
@@ -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.
///
public string? ImageTag { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether an entry point is defined in the Dockerfile.
+ ///
+ ///
+ /// Container images without an entry point are not considered compute resources.
+ ///
+ public bool HasEntrypoint { get; set; } = true;
}
diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceWithStaticDockerFiles.cs b/src/Aspire.Hosting/ApplicationModel/IResourceWithStaticDockerFiles.cs
new file mode 100644
index 00000000000..feae98dad14
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/IResourceWithStaticDockerFiles.cs
@@ -0,0 +1,13 @@
+// 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;
+
+///
+///
+///
+public interface IResourceWithStaticDockerFiles : IResource
+{
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
index 102bf9a0895..4d0763339b2 100644
--- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
@@ -601,6 +601,19 @@ public static int GetReplicaCount(this IResource resource)
}
}
+ ///
+ /// Determines whether the specified resource requires image building.
+ ///
+ ///
+ /// Resources require an image build if they provide their own Dockerfile or are a project.
+ ///
+ /// The resource to evaluate for image build requirements.
+ /// True if the resource requires image building; otherwise, false.
+ public static bool RequiresImageBuild(this IResource resource)
+ {
+ return resource is ProjectResource || resource.TryGetLastAnnotation(out _);
+ }
+
///
/// Determines whether the specified resource requires image building and pushing.
///
@@ -612,7 +625,13 @@ public static int GetReplicaCount(this IResource resource)
/// True if the resource requires image building and pushing; otherwise, false.
public static bool RequiresImageBuildAndPush(this IResource resource)
{
- return resource is ProjectResource || resource.TryGetLastAnnotation(out _);
+ return resource.RequiresImageBuild() && !resource.IsBuildOnlyContainer();
+ }
+
+ internal static bool IsBuildOnlyContainer(this IResource resource)
+ {
+ return resource.TryGetLastAnnotation(out var dockerfileBuild) &&
+ !dockerfileBuild.HasEntrypoint;
}
///
diff --git a/src/Aspire.Hosting/ApplicationModel/StaticDockerFileDestinationAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/StaticDockerFileDestinationAnnotation.cs
new file mode 100644
index 00000000000..f3bdba5418b
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/StaticDockerFileDestinationAnnotation.cs
@@ -0,0 +1,24 @@
+// 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;
+
+///
+/// Represents an annotation that specifies a static file resource to be used as the destination for a resource
+/// operation.
+///
+/// Use this annotation to associate a resource with its required static files, enabling consumers to
+/// access or reference those files as part of resource processing. This type is typically used in scenarios where
+/// static file dependencies must be declared or tracked alongside resource metadata.
+public sealed class StaticDockerFileDestinationAnnotation : IResourceAnnotation
+{
+ ///
+ /// Gets the resource that provides access to static files required by this instance.
+ ///
+ public required IResource Source { get; init; }
+
+ ///
+ /// Gets or sets the file system path where the static files will be saved.
+ ///
+ public required string DestinationPath { get; init; }
+}
diff --git a/src/Aspire.Hosting/ApplicationModel/StaticDockerFilesAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/StaticDockerFilesAnnotation.cs
new file mode 100644
index 00000000000..01c05dbfa68
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/StaticDockerFilesAnnotation.cs
@@ -0,0 +1,20 @@
+// 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;
+
+///
+/// Represents an annotation that associates a static file with a resource.
+///
+/// Use this class to specify the source path of a static file that should be linked or embedded as part
+/// of a resource. This annotation is typically used in scenarios where static assets, such as images or configuration
+/// files, need to be referenced by resource definitions.
+public sealed class StaticDockerFilesAnnotation : IResourceAnnotation
+{
+ ///
+ /// Gets the file system path to the source file or directory.
+ ///
+ public required string SourcePath { get; init; }
+
+ // async GetBasePathAsync() ? use a callback
+}
diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
index b14eb02c9d5..607a514c983 100644
--- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
+++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
@@ -8,6 +8,7 @@
using System.Globalization;
using System.Runtime.ExceptionServices;
using System.Text;
+using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -17,6 +18,7 @@ namespace Aspire.Hosting.Pipelines;
internal sealed class DistributedApplicationPipeline : IDistributedApplicationPipeline
{
private readonly List _steps = [];
+ private readonly List> _configurationCallbacks = [];
public bool HasSteps => _steps.Count > 0;
@@ -103,11 +105,21 @@ public void AddStep(PipelineStep step)
_steps.Add(step);
}
+ public void AddPipelineConfiguration(Func callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ _configurationCallbacks.Add(callback);
+ }
+
public async Task ExecuteAsync(PipelineContext context)
{
- var annotationSteps = await CollectStepsFromAnnotationsAsync(context).ConfigureAwait(false);
+ var (annotationSteps, stepToResourceMap) = await CollectStepsFromAnnotationsAsync(context).ConfigureAwait(false);
var allSteps = _steps.Concat(annotationSteps).ToList();
+ // Execute configuration callbacks even if there are no steps
+ // This allows callbacks to run validation or other logic
+ await ExecuteConfigurationCallbacksAsync(context, allSteps, stepToResourceMap).ConfigureAwait(false);
+
if (allSteps.Count == 0)
{
return;
@@ -182,9 +194,10 @@ void Visit(string stepName)
return result;
}
- private static async Task> CollectStepsFromAnnotationsAsync(PipelineContext context)
+ private static async Task<(List Steps, Dictionary StepToResourceMap)> CollectStepsFromAnnotationsAsync(PipelineContext context)
{
var steps = new List();
+ var stepToResourceMap = new Dictionary();
foreach (var resource in context.Model.Resources)
{
@@ -200,11 +213,53 @@ private static async Task> CollectStepsFromAnnotationsAsync(P
};
var annotationSteps = await annotation.CreateStepsAsync(factoryContext).ConfigureAwait(false);
- steps.AddRange(annotationSteps);
+ foreach (var step in annotationSteps)
+ {
+ steps.Add(step);
+ stepToResourceMap[step] = resource;
+ }
}
}
- return steps;
+ return (steps, stepToResourceMap);
+ }
+
+ private async Task ExecuteConfigurationCallbacksAsync(
+ PipelineContext pipelineContext,
+ List allSteps,
+ Dictionary stepToResourceMap)
+ {
+ // Collect callbacks from the pipeline itself
+ var callbacks = new List>();
+
+ callbacks.AddRange(_configurationCallbacks);
+
+ // Collect callbacks from resource annotations
+ foreach (var resource in pipelineContext.Model.Resources)
+ {
+ var annotations = resource.Annotations.OfType();
+ foreach (var annotation in annotations)
+ {
+ callbacks.Add(annotation.Callback);
+ }
+ }
+
+ // Execute all callbacks
+ if (callbacks.Count > 0)
+ {
+ var configContext = new PipelineConfigurationContext
+ {
+ Services = pipelineContext.Services,
+ Steps = allSteps.AsReadOnly(),
+ ApplicationModel = pipelineContext.Model,
+ StepToResourceMap = stepToResourceMap
+ };
+
+ foreach (var callback in callbacks)
+ {
+ await callback(configContext).ConfigureAwait(false);
+ }
+ }
}
private static void ValidateSteps(IEnumerable steps)
diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
index d6d971b8f0a..2fd24ea2238 100644
--- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
+++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs
@@ -31,6 +31,12 @@ void AddStep(string name,
/// The pipeline step to add.
void AddStep(PipelineStep step);
+ ///
+ /// Registers a callback to be executed during the pipeline configuration phase.
+ ///
+ /// The callback function to execute during the configuration phase.
+ void AddPipelineConfiguration(Func callback);
+
///
/// Executes all steps in the pipeline in dependency order.
///
diff --git a/src/Aspire.Hosting/Pipelines/PipelineBuildComputeStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineBuildComputeStepAnnotation.cs
new file mode 100644
index 00000000000..2d747e22d91
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineBuildComputeStepAnnotation.cs
@@ -0,0 +1,22 @@
+// 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 ASPIREPUBLISHERS001
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// An annotation that can be applied to a resource to indicate that it has its own
+/// pipeline build step that produces a compute image.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class PipelineBuildComputeStepAnnotation : IResourceAnnotation
+{
+ ///
+ /// Gets the name of the step that builds the compute image.
+ ///
+ public required string StepName { get; init; }
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineConfigurationAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineConfigurationAnnotation.cs
new file mode 100644
index 00000000000..94a9b40310a
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineConfigurationAnnotation.cs
@@ -0,0 +1,46 @@
+// 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 ASPIREPUBLISHERS001
+
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// An annotation that registers a callback to execute during the pipeline configuration phase,
+/// allowing modification of step dependencies and relationships.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class PipelineConfigurationAnnotation : IResourceAnnotation
+{
+ ///
+ /// Gets the callback function to execute during the configuration phase.
+ ///
+ public Func Callback { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The callback function to execute during the configuration phase.
+ public PipelineConfigurationAnnotation(Func callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ Callback = callback;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The synchronous callback function to execute during the configuration phase.
+ public PipelineConfigurationAnnotation(Action callback)
+ {
+ ArgumentNullException.ThrowIfNull(callback);
+ Callback = (context) =>
+ {
+ callback(context);
+ return Task.CompletedTask;
+ };
+ }
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineConfigurationContext.cs b/src/Aspire.Hosting/Pipelines/PipelineConfigurationContext.cs
new file mode 100644
index 00000000000..dd8c1b2c00f
--- /dev/null
+++ b/src/Aspire.Hosting/Pipelines/PipelineConfigurationContext.cs
@@ -0,0 +1,66 @@
+// 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;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.Pipelines;
+
+///
+/// Provides contextual information for pipeline configuration callbacks.
+///
+[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public class PipelineConfigurationContext
+{
+ ///
+ /// Gets the service provider for dependency resolution.
+ ///
+ public required IServiceProvider Services { get; init; }
+
+ ///
+ /// Gets the list of pipeline steps collected during the first pass.
+ ///
+ public required IReadOnlyList Steps { get; init; }
+
+ ///
+ /// Gets the distributed application model containing all resources.
+ ///
+ public required DistributedApplicationModel ApplicationModel { get; init; }
+
+ internal IReadOnlyDictionary StepToResourceMap { get; init; } = null!;
+
+ ///
+ /// Finds all pipeline steps with the specified tag.
+ ///
+ /// The tag to search for.
+ /// A collection of steps that have the specified tag.
+ public IEnumerable FindStepsByTag(string tag)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ return Steps.Where(s => s.Tags.Contains(tag));
+ }
+
+ ///
+ /// Finds all pipeline steps associated with the specified resource.
+ ///
+ /// The resource to search for.
+ /// A collection of steps associated with the resource.
+ public IEnumerable FindStepsByResource(IResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ return StepToResourceMap.Where(kvp => kvp.Value == resource).Select(kvp => kvp.Key);
+ }
+
+ ///
+ /// Finds all pipeline steps with the specified tag that are associated with the specified resource.
+ ///
+ /// The tag to search for.
+ /// The resource to search for.
+ /// A collection of steps that have the specified tag and are associated with the resource.
+ public IEnumerable FindStepsByTagAndResource(string tag, IResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ ArgumentNullException.ThrowIfNull(resource);
+ return FindStepsByResource(resource).Where(s => s.Tags.Contains(tag));
+ }
+}
diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs
index 32c583d3226..44805c29ca8 100644
--- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs
+++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs
@@ -33,6 +33,11 @@ public class PipelineStep
///
public List RequiredBySteps { get; init; } = [];
+ ///
+ /// Gets or initializes the list of tags that categorize this step.
+ ///
+ public List Tags { get; init; } = [];
+
///
/// Adds a dependency on another step.
///
diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepExtensions.cs b/src/Aspire.Hosting/Pipelines/PipelineStepExtensions.cs
index 303cd01ada4..153bfedb5fc 100644
--- a/src/Aspire.Hosting/Pipelines/PipelineStepExtensions.cs
+++ b/src/Aspire.Hosting/Pipelines/PipelineStepExtensions.cs
@@ -81,4 +81,40 @@ public static IResourceBuilder WithPipelineStepFactory(
return builder.WithAnnotation(new PipelineStepAnnotation(factory));
}
+
+ ///
+ /// Registers a callback to be executed during the pipeline configuration phase,
+ /// allowing modification of step dependencies and relationships.
+ ///
+ /// The type of the resource.
+ /// The resource builder.
+ /// The callback function to execute during the configuration phase.
+ /// The resource builder for chaining.
+ public static IResourceBuilder WithPipelineConfiguration(
+ this IResourceBuilder builder,
+ Func callback) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(callback);
+
+ return builder.WithAnnotation(new PipelineConfigurationAnnotation(callback));
+ }
+
+ ///
+ /// Registers a callback to be executed during the pipeline configuration phase,
+ /// allowing modification of step dependencies and relationships.
+ ///
+ /// The type of the resource.
+ /// The resource builder.
+ /// The callback function to execute during the configuration phase.
+ /// The resource builder for chaining.
+ public static IResourceBuilder WithPipelineConfiguration(
+ this IResourceBuilder builder,
+ Action callback) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(callback);
+
+ return builder.WithAnnotation(new PipelineConfigurationAnnotation(callback));
+ }
}
diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineTags.cs
similarity index 68%
rename from src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs
rename to src/Aspire.Hosting/Pipelines/WellKnownPipelineTags.cs
index e769971b9bc..7a163adb1a8 100644
--- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs
+++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineTags.cs
@@ -6,23 +6,23 @@
namespace Aspire.Hosting.Pipelines;
///
-/// Defines well-known pipeline step names used in the deployment process.
+/// Defines well-known pipeline tags used to categorize pipeline steps.
///
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
-public static class WellKnownPipelineSteps
+public static class WellKnownPipelineTags
{
///
- /// The step that provisions infrastructure resources.
+ /// Tag for steps that provision infrastructure resources.
///
public const string ProvisionInfrastructure = "provision-infra";
///
- /// The step that builds compute resources.
+ /// Tag for steps that build compute resources.
///
public const string BuildCompute = "build-compute";
///
- /// The step that deploys to compute infrastructure.
+ /// Tag for steps that deploy to compute infrastructure.
///
public const string DeployCompute = "deploy-compute";
}
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index 5b5dd63585b..8c545dae70e 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -1270,6 +1270,30 @@ public static IResourceBuilder WithUrlForEndpoint(this IResourceBuilder
return builder;
}
+ ///
+ /// Adds static file resource information to the specified resource builder, enabling the resource to be associated
+ /// with static files for deployment or serving.
+ ///
+ /// Use this method to indicate that the resource should include or serve static files as part of
+ /// its deployment. This is typically used in scenarios where resources need to expose static content, such as web
+ /// assets.
+ /// The type of resource being built. Must implement .
+ /// The resource builder to which static file information will be added. Cannot be null.
+ /// An object representing the static files to associate with the resource. Specifies the source of static files for
+ /// the resource.
+ ///
+ /// The same resource builder instance with static file information attached, allowing for further configuration or
+ /// chaining.
+ public static IResourceBuilder PublishWithStaticFiles(
+ this IResourceBuilder builder,
+ IResourceBuilder staticFilesResource,
+ string destination) where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithAnnotation(new StaticDockerFileDestinationAnnotation() { Source = staticFilesResource.Resource, DestinationPath = destination });
+ }
+
///
/// Excludes a resource from being published to the manifest.
///
diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile
deleted file mode 100644
index ca86e795dcb..00000000000
--- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/Dockerfile
+++ /dev/null
@@ -1,57 +0,0 @@
-# Stage 1: Build the Vite app
-FROM node:22-slim AS frontend-stage
-
-# Set the working directory inside the container
-COPY frontend ./
-
-WORKDIR /frontend
-RUN npm install
-RUN npm run build
-
-# Stage 2: Build the Python application with UV
-FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
-
-# Enable bytecode compilation and copy mode for the virtual environment
-ENV UV_COMPILE_BYTECODE=1
-ENV UV_LINK_MODE=copy
-
-WORKDIR /app
-
-# Install dependencies first for better layer caching
-# Uses BuildKit cache mounts to speed up repeated builds
-RUN --mount=type=cache,target=/root/.cache/uv --mount=type=bind,source=./app/uv.lock,target=uv.lock --mount=type=bind,source=./app/pyproject.toml,target=pyproject.toml \
- uv sync --locked --no-install-project --no-dev
-
-# Copy the rest of the application source and install the project
-COPY ./app /app
-RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --locked --no-dev
-
-# Stage 3: Create the final runtime image
-FROM python:3.13-slim-bookworm AS app
-
-COPY --from=frontend-stage /dist /app/static
-
-# ------------------------------
-# 🚀 Runtime stage
-# ------------------------------
-# Create non-root user for security
-RUN groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser
-
-# Copy the application and virtual environment from builder
-COPY --from=builder --chown=appuser:appuser /app /app
-
-# Add virtual environment to PATH and set VIRTUAL_ENV
-ENV PATH=/app/.venv/bin:${PATH}
-ENV VIRTUAL_ENV=/app/.venv
-ENV PYTHONDONTWRITEBYTECODE=1
-ENV PYTHONUNBUFFERED=1
-
-# Use the non-root user to run the application
-USER appuser
-
-# Set working directory
-WORKDIR /app
-
-# Run the application
-ENTRYPOINT ["fastapi", "run", "app.py", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py
index 554594b504c..641e3fb4078 100644
--- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py
+++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/app/app.py
@@ -130,12 +130,3 @@ async def health_check():
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")
diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs
index 4824a97aa32..5dc45739f80 100644
--- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs
+++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs
@@ -13,23 +13,20 @@
var cache = builder.AddRedis("cache");
#endif
-var apiService = builder.AddPythonScript("app", "./app", "app.py")
+var app = builder.AddUvicornApp("app", "./app", "app:app")
.WithUvEnvironment()
- .WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
- .WithHttpHealthCheck("/health")
#if UseRedisCache
.WithReference(cache)
.WaitFor(cache)
#endif
- .PublishAsDockerFile(c =>
- {
- c.WithDockerfile(".");
- });
+ .WithHttpHealthCheck("/health");
-builder.AddViteApp("frontend", "./frontend")
+var frontend = builder.AddViteApp("frontend", "./frontend")
.WithNpmPackageManager()
- .WithReference(apiService)
- .WaitFor(apiService);
+ .WithReference(app)
+ .WaitFor(app);
+
+app.PublishWithStaticFiles(frontend, "./static");
builder.Build().Run();
diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
index 9e6994c3251..72b82053757 100644
--- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
+++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs
@@ -1893,6 +1893,413 @@ await Assert.ThrowsAsync(async () =>
Assert.True(foundErrorActivity, $"Expected to find a task activity with detailed error message about invalid step. Got: {errorMessage}");
}
+ [Fact]
+ public async Task PipelineStep_WithTags_StoresTagsCorrectly()
+ {
+ var step = new PipelineStep
+ {
+ Name = "test-step",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["tag1", "tag2"]
+ };
+
+ Assert.Equal(2, step.Tags.Count);
+ Assert.Contains("tag1", step.Tags);
+ Assert.Contains("tag2", step.Tags);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithConfigurationCallback_ExecutesCallback()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+ var pipeline = new DistributedApplicationPipeline();
+
+ var callbackExecuted = false;
+ var capturedSteps = new List();
+
+ pipeline.AddStep("step1", async (context) => await Task.CompletedTask);
+ pipeline.AddStep("step2", async (context) => await Task.CompletedTask);
+
+ pipeline.AddPipelineConfiguration((configContext) =>
+ {
+ callbackExecuted = true;
+ capturedSteps.AddRange(configContext.Steps);
+ return Task.CompletedTask;
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.True(callbackExecuted);
+ Assert.Equal(2, capturedSteps.Count);
+ Assert.Contains(capturedSteps, s => s.Name == "step1");
+ Assert.Contains(capturedSteps, s => s.Name == "step2");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ConfigurationCallback_CanModifyDependencies()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+ var pipeline = new DistributedApplicationPipeline();
+
+ var executionOrder = new List();
+
+ pipeline.AddStep("step1", async (context) =>
+ {
+ lock (executionOrder) { executionOrder.Add("step1"); }
+ await Task.CompletedTask;
+ });
+
+ pipeline.AddStep("step2", async (context) =>
+ {
+ lock (executionOrder) { executionOrder.Add("step2"); }
+ await Task.CompletedTask;
+ });
+
+ // Add a callback that makes step2 depend on step1
+ pipeline.AddPipelineConfiguration((configContext) =>
+ {
+ var step1 = configContext.Steps.First(s => s.Name == "step1");
+ var step2 = configContext.Steps.First(s => s.Name == "step2");
+ step2.DependsOn(step1);
+ return Task.CompletedTask;
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.Equal(["step1", "step2"], executionOrder);
+ }
+
+ [Fact]
+ public async Task PipelineConfigurationContext_FindStepsByTag_ReturnsCorrectSteps()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+ var pipeline = new DistributedApplicationPipeline();
+
+ var foundSteps = new List();
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step1",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["test-tag"]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step2",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["test-tag", "another-tag"]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "step3",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["different-tag"]
+ });
+
+ pipeline.AddPipelineConfiguration((configContext) =>
+ {
+ foundSteps.AddRange(configContext.FindStepsByTag("test-tag"));
+ return Task.CompletedTask;
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.Equal(2, foundSteps.Count);
+ Assert.Contains(foundSteps, s => s.Name == "step1");
+ Assert.Contains(foundSteps, s => s.Name == "step2");
+ Assert.DoesNotContain(foundSteps, s => s.Name == "step3");
+ }
+
+ [Fact]
+ public async Task PipelineConfigurationContext_FindStepsByResource_ReturnsCorrectSteps()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ var foundSteps = new List();
+ IResource? targetResource = null;
+
+ var resource1 = builder.AddResource(new CustomResource("resource1"))
+ .WithPipelineStepFactory((factoryContext) =>
+ [
+ new PipelineStep
+ {
+ Name = "resource1-step1",
+ Action = async (ctx) => await Task.CompletedTask
+ },
+ new PipelineStep
+ {
+ Name = "resource1-step2",
+ Action = async (ctx) => await Task.CompletedTask
+ }
+ ]);
+
+ var resource2 = builder.AddResource(new CustomResource("resource2"))
+ .WithPipelineStepFactory((factoryContext) =>
+ {
+ targetResource = factoryContext.Resource;
+ return new PipelineStep
+ {
+ Name = "resource2-step1",
+ Action = async (ctx) => await Task.CompletedTask
+ };
+ })
+ .WithPipelineConfiguration((configContext) =>
+ {
+ var resource2Instance = configContext.ApplicationModel.Resources.FirstOrDefault(r => r.Name == "resource2");
+ if (resource2Instance != null)
+ {
+ foundSteps.AddRange(configContext.FindStepsByResource(resource2Instance));
+ }
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.Single(foundSteps);
+ Assert.Contains(foundSteps, s => s.Name == "resource2-step1");
+ }
+
+ [Fact]
+ public async Task PipelineConfigurationContext_FindStepsByTagAndResource_ReturnsCorrectSteps()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ var foundSteps = new List();
+
+ var resource1 = builder.AddResource(new CustomResource("resource1"))
+ .WithPipelineStepFactory((factoryContext) =>
+ [
+ new PipelineStep
+ {
+ Name = "resource1-step1",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["build"]
+ },
+ new PipelineStep
+ {
+ Name = "resource1-step2",
+ Action = async (ctx) => await Task.CompletedTask,
+ Tags = ["deploy"]
+ }
+ ])
+ .WithPipelineConfiguration((configContext) =>
+ {
+ var resource1Instance = configContext.ApplicationModel.Resources.FirstOrDefault(r => r.Name == "resource1");
+ if (resource1Instance != null)
+ {
+ foundSteps.AddRange(configContext.FindStepsByTagAndResource("build", resource1Instance));
+ }
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.Single(foundSteps);
+ Assert.Contains(foundSteps, s => s.Name == "resource1-step1");
+ }
+
+ [Fact]
+ public async Task WithPipelineConfiguration_AsyncOverload_ExecutesCallback()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ var callbackExecuted = false;
+
+ var resource = builder.AddResource(new CustomResource("test-resource"))
+ .WithPipelineConfiguration(async (configContext) =>
+ {
+ await Task.CompletedTask;
+ callbackExecuted = true;
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.True(callbackExecuted);
+ }
+
+ [Fact]
+ public async Task WithPipelineConfiguration_SyncOverload_ExecutesCallback()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ var callbackExecuted = false;
+
+ var resource = builder.AddResource(new CustomResource("test-resource"))
+ .WithPipelineConfiguration((configContext) =>
+ {
+ callbackExecuted = true;
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.True(callbackExecuted);
+ }
+
+ [Fact]
+ public async Task ConfigurationCallback_CanAccessApplicationModel()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ IResource? capturedResource = null;
+
+ var resource = builder.AddResource(new CustomResource("test-resource"))
+ .WithPipelineConfiguration((configContext) =>
+ {
+ capturedResource = configContext.ApplicationModel.Resources.FirstOrDefault(r => r.Name == "test-resource");
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.NotNull(capturedResource);
+ Assert.Equal("test-resource", capturedResource.Name);
+ }
+
+ [Fact]
+ public async Task ConfigurationCallback_ExecutesAfterStepCollection()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+
+ var allStepsAvailable = false;
+
+ builder.AddResource(new CustomResource("resource1"))
+ .WithPipelineStepFactory((factoryContext) => new PipelineStep
+ {
+ Name = "resource1-step",
+ Action = async (ctx) => await Task.CompletedTask
+ });
+
+ builder.AddResource(new CustomResource("resource2"))
+ .WithPipelineConfiguration((configContext) =>
+ {
+ // Check if steps from other resources are available
+ allStepsAvailable = configContext.Steps.Any(s => s.Name == "resource1-step");
+ });
+
+ var pipeline = new DistributedApplicationPipeline();
+ pipeline.AddStep("direct-step", async (context) => await Task.CompletedTask);
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ Assert.True(allStepsAvailable, "Configuration phase should have access to all collected steps");
+ }
+
+ [Fact]
+ public void WellKnownPipelineTags_ConstantsAccessible()
+ {
+ // Verify the WellKnownPipelineTags class has the expected constants
+ Assert.Equal("provision-infra", WellKnownPipelineTags.ProvisionInfrastructure);
+ Assert.Equal("build-compute", WellKnownPipelineTags.BuildCompute);
+ Assert.Equal("deploy-compute", WellKnownPipelineTags.DeployCompute);
+ }
+
+ [Fact]
+ public async Task ConfigurationCallback_CanCreateComplexDependencyRelationships()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true);
+ var pipeline = new DistributedApplicationPipeline();
+
+ var executionOrder = new List();
+
+ // Create steps with tags
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "provision1",
+ Action = async (ctx) =>
+ {
+ lock (executionOrder) { executionOrder.Add("provision1"); }
+ await Task.CompletedTask;
+ },
+ Tags = [WellKnownPipelineTags.ProvisionInfrastructure]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "provision2",
+ Action = async (ctx) =>
+ {
+ lock (executionOrder) { executionOrder.Add("provision2"); }
+ await Task.CompletedTask;
+ },
+ Tags = [WellKnownPipelineTags.ProvisionInfrastructure]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "build1",
+ Action = async (ctx) =>
+ {
+ lock (executionOrder) { executionOrder.Add("build1"); }
+ await Task.CompletedTask;
+ },
+ Tags = [WellKnownPipelineTags.BuildCompute]
+ });
+
+ pipeline.AddStep(new PipelineStep
+ {
+ Name = "deploy1",
+ Action = async (ctx) =>
+ {
+ lock (executionOrder) { executionOrder.Add("deploy1"); }
+ await Task.CompletedTask;
+ },
+ Tags = [WellKnownPipelineTags.DeployCompute]
+ });
+
+ // Use configuration phase to make all build steps depend on all provision steps
+ // and all deploy steps depend on all build steps
+ pipeline.AddPipelineConfiguration((configContext) =>
+ {
+ var provisionSteps = configContext.FindStepsByTag(WellKnownPipelineTags.ProvisionInfrastructure).ToList();
+ var buildSteps = configContext.FindStepsByTag(WellKnownPipelineTags.BuildCompute).ToList();
+ var deploySteps = configContext.FindStepsByTag(WellKnownPipelineTags.DeployCompute).ToList();
+
+ foreach (var buildStep in buildSteps)
+ {
+ foreach (var provisionStep in provisionSteps)
+ {
+ buildStep.DependsOn(provisionStep);
+ }
+ }
+
+ foreach (var deployStep in deploySteps)
+ {
+ foreach (var buildStep in buildSteps)
+ {
+ deployStep.DependsOn(buildStep);
+ }
+ }
+
+ return Task.CompletedTask;
+ });
+
+ var context = CreateDeployingContext(builder.Build());
+ await pipeline.ExecuteAsync(context);
+
+ // Verify order: all provisions before builds, all builds before deploys
+ var provision1Index = executionOrder.IndexOf("provision1");
+ var provision2Index = executionOrder.IndexOf("provision2");
+ var build1Index = executionOrder.IndexOf("build1");
+ var deploy1Index = executionOrder.IndexOf("deploy1");
+
+ Assert.True(provision1Index < build1Index, "provision1 should execute before build1");
+ Assert.True(provision2Index < build1Index, "provision2 should execute before build1");
+ Assert.True(build1Index < deploy1Index, "build1 should execute before deploy1");
+ }
+
private sealed class CustomResource(string name) : Resource(name)
{
}