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..9aaa8128dac 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -82,15 +82,17 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var provisionStep = new PipelineStep { - Name = WellKnownPipelineSteps.ProvisionInfrastructure, - Action = ctx => ProvisionAzureBicepResourcesAsync(ctx, provisioningContext!) + Name = "provision-azure-bicep-resources", + Action = ctx => ProvisionAzureBicepResourcesAsync(ctx, provisioningContext!), + Tags = [WellKnownPipelineTags.ProvisionInfrastructure] }; provisionStep.DependsOn(createContextStep); var buildStep = new PipelineStep { - Name = WellKnownPipelineSteps.BuildCompute, - Action = ctx => BuildContainerImagesAsync(ctx) + Name = "build-container-images", + Action = ctx => BuildContainerImagesAsync(ctx), + Tags = [WellKnownPipelineTags.BuildCompute] }; var pushStep = new PipelineStep @@ -103,8 +105,9 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var deployStep = new PipelineStep { - Name = WellKnownPipelineSteps.DeployCompute, - Action = ctx => DeployComputeResourcesAsync(ctx, provisioningContext!) + Name = "deploy-compute-resources", + Action = ctx => DeployComputeResourcesAsync(ctx, provisioningContext!), + Tags = [WellKnownPipelineTags.DeployCompute] }; deployStep.DependsOn(pushStep); deployStep.DependsOn(provisionStep); diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index b14eb02c9d5..c6b669b246c 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(), + Model = 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/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..c11d74778f2 --- /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 Model { get; init; } + + internal IReadOnlyDictionary StepToResourceMap { get; init; } = null!; + + /// + /// Gets all pipeline steps with the specified tag. + /// + /// The tag to search for. + /// A collection of steps that have the specified tag. + public IEnumerable GetSteps(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + return Steps.Where(s => s.Tags.Contains(tag)); + } + + /// + /// Gets all pipeline steps associated with the specified resource. + /// + /// The resource to search for. + /// A collection of steps associated with the resource. + public IEnumerable GetSteps(IResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + return StepToResourceMap.Where(kvp => kvp.Value == resource).Select(kvp => kvp.Key); + } + + /// + /// Gets all pipeline steps with the specified tag that are associated with the specified resource. + /// + /// The resource to search for. + /// The tag to search for. + /// A collection of steps that have the specified tag and are associated with the resource. + public IEnumerable GetSteps(IResource resource, string tag) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(tag); + return GetSteps(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/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 9e6994c3251..53aac0b37fb 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -1699,6 +1699,406 @@ public async Task ExecuteAsync_PipelineLoggerProvider_RespectsPublishingLogLevel } } + [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; + }); + + 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_GetStepsByTag_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.GetSteps("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_GetStepsByResource_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.Model.Resources.FirstOrDefault(r => r.Name == "resource2"); + if (resource2Instance != null) + { + foundSteps.AddRange(configContext.GetSteps(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_GetStepsByResourceAndTag_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.Model.Resources.FirstOrDefault(r => r.Name == "resource1"); + if (resource1Instance != null) + { + foundSteps.AddRange(configContext.GetSteps(resource1Instance, "build")); + } + }); + + 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_CanAccessModel() + { + 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.Model.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) => + { + 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() + { + 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(); + + 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] + }); + + pipeline.AddPipelineConfiguration((configContext) => + { + var provisionSteps = configContext.GetSteps(WellKnownPipelineTags.ProvisionInfrastructure).ToList(); + var buildSteps = configContext.GetSteps(WellKnownPipelineTags.BuildCompute).ToList(); + var deploySteps = configContext.GetSteps(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); + + 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"); + } + [Fact] public async Task ExecuteAsync_WithNonExistentStepFilter_ThrowsInvalidOperationExceptionWithAvailableSteps() {