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) { }