Skip to content
Merged
4 changes: 2 additions & 2 deletions playground/pipelines/Pipelines.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
{
Expand Down Expand Up @@ -324,7 +324,7 @@ await uploadTask.CompleteAsync(
totalUploads += fileCount;
}
}
}, requiredBy: WellKnownPipelineSteps.DeployCompute, dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure);
}, requiredBy: WellKnownPipelineTags.DeployCompute, dependsOn: WellKnownPipelineTags.ProvisionInfrastructure);

builder.AddProject<Projects.Publishers_ApiService>("api-service")
.WithComputeEnvironment(aasEnv)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ await DeployProjectToAppServiceAsync(
}
}
}
}, dependsOn: WellKnownPipelineSteps.DeployCompute);
}, dependsOn: WellKnownPipelineTags.DeployCompute);

return pipeline;
}
Expand Down
15 changes: 9 additions & 6 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
63 changes: 59 additions & 4 deletions src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,6 +18,7 @@ namespace Aspire.Hosting.Pipelines;
internal sealed class DistributedApplicationPipeline : IDistributedApplicationPipeline
{
private readonly List<PipelineStep> _steps = [];
private readonly List<Func<PipelineConfigurationContext, Task>> _configurationCallbacks = [];

public bool HasSteps => _steps.Count > 0;

Expand Down Expand Up @@ -103,11 +105,21 @@ public void AddStep(PipelineStep step)
_steps.Add(step);
}

public void AddPipelineConfiguration(Func<PipelineConfigurationContext, Task> 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);
Comment on lines +119 to +121
Copy link

Copilot AI Oct 23, 2025

Choose a reason for hiding this comment

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

Remove the explanatory comments about executing configuration callbacks. The method name ExecuteConfigurationCallbacksAsync is self-documenting, and the comment simply restates what the code does rather than explaining why or providing useful context.

Copilot generated this review using guidance from repository custom instructions.

if (allSteps.Count == 0)
{
return;
Expand Down Expand Up @@ -182,9 +194,10 @@ void Visit(string stepName)
return result;
}

private static async Task<List<PipelineStep>> CollectStepsFromAnnotationsAsync(PipelineContext context)
private static async Task<(List<PipelineStep> Steps, Dictionary<PipelineStep, IResource> StepToResourceMap)> CollectStepsFromAnnotationsAsync(PipelineContext context)
{
var steps = new List<PipelineStep>();
var stepToResourceMap = new Dictionary<PipelineStep, IResource>();

foreach (var resource in context.Model.Resources)
{
Expand All @@ -200,11 +213,53 @@ private static async Task<List<PipelineStep>> 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<PipelineStep> allSteps,
Dictionary<PipelineStep, IResource> stepToResourceMap)
{
// Collect callbacks from the pipeline itself
var callbacks = new List<Func<PipelineConfigurationContext, Task>>();

callbacks.AddRange(_configurationCallbacks);

// Collect callbacks from resource annotations
foreach (var resource in pipelineContext.Model.Resources)
{
var annotations = resource.Annotations.OfType<PipelineConfigurationAnnotation>();
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<PipelineStep> steps)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ void AddStep(string name,
/// <param name="step">The pipeline step to add.</param>
void AddStep(PipelineStep step);

/// <summary>
/// Registers a callback to be executed during the pipeline configuration phase.
/// </summary>
/// <param name="callback">The callback function to execute during the configuration phase.</param>
void AddPipelineConfiguration(Func<PipelineConfigurationContext, Task> callback);

/// <summary>
/// Executes all steps in the pipeline in dependency order.
/// </summary>
Expand Down
46 changes: 46 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineConfigurationAnnotation.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An annotation that registers a callback to execute during the pipeline configuration phase,
/// allowing modification of step dependencies and relationships.
/// </summary>
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class PipelineConfigurationAnnotation : IResourceAnnotation
{
/// <summary>
/// Gets the callback function to execute during the configuration phase.
/// </summary>
public Func<PipelineConfigurationContext, Task> Callback { get; }

/// <summary>
/// Initializes a new instance of the <see cref="PipelineConfigurationAnnotation"/> class.
/// </summary>
/// <param name="callback">The callback function to execute during the configuration phase.</param>
public PipelineConfigurationAnnotation(Func<PipelineConfigurationContext, Task> callback)
{
ArgumentNullException.ThrowIfNull(callback);
Callback = callback;
}

/// <summary>
/// Initializes a new instance of the <see cref="PipelineConfigurationAnnotation"/> class.
/// </summary>
/// <param name="callback">The synchronous callback function to execute during the configuration phase.</param>
public PipelineConfigurationAnnotation(Action<PipelineConfigurationContext> callback)
{
ArgumentNullException.ThrowIfNull(callback);
Callback = (context) =>
{
callback(context);
return Task.CompletedTask;
};
}
}
66 changes: 66 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineConfigurationContext.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides contextual information for pipeline configuration callbacks.
/// </summary>
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class PipelineConfigurationContext
{
/// <summary>
/// Gets the service provider for dependency resolution.
/// </summary>
public required IServiceProvider Services { get; init; }

/// <summary>
/// Gets the list of pipeline steps collected during the first pass.
/// </summary>
public required IReadOnlyList<PipelineStep> Steps { get; init; }

/// <summary>
/// Gets the distributed application model containing all resources.
/// </summary>
public required DistributedApplicationModel Model { get; init; }

internal IReadOnlyDictionary<PipelineStep, IResource> StepToResourceMap { get; init; } = null!;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
internal IReadOnlyDictionary<PipelineStep, IResource> StepToResourceMap { get; init; } = null!;
internal required IReadOnlyDictionary<PipelineStep, IResource> StepToResourceMap { get; init; }

Copy link
Member

Choose a reason for hiding this comment

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

This is unfortunate that it is internal, but the ctor is public. If we want to keep it that way, we should reall make this:

Suggested change
internal IReadOnlyDictionary<PipelineStep, IResource> StepToResourceMap { get; init; } = null!;
internal IReadOnlyDictionary<PipelineStep, IResource>? StepToResourceMap { get; init; }


/// <summary>
/// Gets all pipeline steps with the specified tag.
/// </summary>
/// <param name="tag">The tag to search for.</param>
/// <returns>A collection of steps that have the specified tag.</returns>
public IEnumerable<PipelineStep> GetSteps(string tag)
{
ArgumentNullException.ThrowIfNull(tag);
return Steps.Where(s => s.Tags.Contains(tag));
}

/// <summary>
/// Gets all pipeline steps associated with the specified resource.
/// </summary>
/// <param name="resource">The resource to search for.</param>
/// <returns>A collection of steps associated with the resource.</returns>
public IEnumerable<PipelineStep> GetSteps(IResource resource)
{
ArgumentNullException.ThrowIfNull(resource);
return StepToResourceMap.Where(kvp => kvp.Value == resource).Select(kvp => kvp.Key);
}

/// <summary>
/// Gets all pipeline steps with the specified tag that are associated with the specified resource.
/// </summary>
/// <param name="resource">The resource to search for.</param>
/// <param name="tag">The tag to search for.</param>
/// <returns>A collection of steps that have the specified tag and are associated with the resource.</returns>
public IEnumerable<PipelineStep> GetSteps(IResource resource, string tag)
{
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(tag);
return GetSteps(resource).Where(s => s.Tags.Contains(tag));
}
}
5 changes: 5 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public class PipelineStep
/// </summary>
public List<string> RequiredBySteps { get; init; } = [];

/// <summary>
/// Gets or initializes the list of tags that categorize this step.
/// </summary>
public List<string> Tags { get; init; } = [];

/// <summary>
/// Adds a dependency on another step.
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions src/Aspire.Hosting/Pipelines/PipelineStepExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,40 @@ public static IResourceBuilder<T> WithPipelineStepFactory<T>(

return builder.WithAnnotation(new PipelineStepAnnotation(factory));
}

/// <summary>
/// Registers a callback to be executed during the pipeline configuration phase,
/// allowing modification of step dependencies and relationships.
/// </summary>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="callback">The callback function to execute during the configuration phase.</param>
/// <returns>The resource builder for chaining.</returns>
public static IResourceBuilder<T> WithPipelineConfiguration<T>(
this IResourceBuilder<T> builder,
Func<PipelineConfigurationContext, Task> callback) where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);

return builder.WithAnnotation(new PipelineConfigurationAnnotation(callback));
}

/// <summary>
/// Registers a callback to be executed during the pipeline configuration phase,
/// allowing modification of step dependencies and relationships.
/// </summary>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="callback">The callback function to execute during the configuration phase.</param>
/// <returns>The resource builder for chaining.</returns>
public static IResourceBuilder<T> WithPipelineConfiguration<T>(
this IResourceBuilder<T> builder,
Action<PipelineConfigurationContext> callback) where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(callback);

return builder.WithAnnotation(new PipelineConfigurationAnnotation(callback));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@
namespace Aspire.Hosting.Pipelines;

/// <summary>
/// Defines well-known pipeline step names used in the deployment process.
/// Defines well-known pipeline tags used to categorize pipeline steps.
/// </summary>
[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static class WellKnownPipelineSteps
public static class WellKnownPipelineTags
{
/// <summary>
/// The step that provisions infrastructure resources.
/// Tag for steps that provision infrastructure resources.
/// </summary>
public const string ProvisionInfrastructure = "provision-infra";

/// <summary>
/// The step that builds compute resources.
/// Tag for steps that build compute resources.
/// </summary>
public const string BuildCompute = "build-compute";

/// <summary>
/// The step that deploys to compute infrastructure.
/// Tag for steps that deploy to compute infrastructure.
/// </summary>
public const string DeployCompute = "deploy-compute";
}
Loading