Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Aspire.Cli/Commands/DeployCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace Aspire.Cli.Commands;
internal sealed class DeployCommand : PublishCommandBase
{
private readonly Option<bool> _clearCacheOption;
private readonly Option<string?> _stepOption;

public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
: base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment)
Expand All @@ -24,6 +25,12 @@ public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionSer
Description = "Clear the deployment cache associated with the current environment and do not save deployment state"
};
Options.Add(_clearCacheOption);

_stepOption = new Option<string?>("--step")
{
Description = "Run a specific deployment step and its dependencies"
};
Options.Add(_stepOption);
}

protected override string OperationCompletedPrefix => DeployCommandStrings.OperationCompletedPrefix;
Expand Down Expand Up @@ -61,6 +68,12 @@ protected override string[] GetRunArguments(string? fullyQualifiedOutputPath, st
baseArgs.AddRange(["--environment", environment!]);
}

var step = parseResult.GetValue(_stepOption);
if (step != null)
{
baseArgs.AddRange(["--step", step]);
}

baseArgs.AddRange(unmatchedTokens);

return [.. baseArgs];
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ private void ConfigurePublishingOptions(DistributedApplicationOptions options)
{ "--deploy", "Publishing:Deploy" },
{ "--log-level", "Publishing:LogLevel" },
{ "--clear-cache", "Publishing:ClearCache" },
{ "--step", "Publishing:Step" },
{ "--dcp-cli-path", "DcpPublisher:CliPath" },
{ "--dcp-container-runtime", "DcpPublisher:ContainerRuntime" },
{ "--dcp-dependency-check-timeout", "DcpPublisher:DependencyCheckTimeout" },
Expand Down
79 changes: 72 additions & 7 deletions src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,71 @@ public async Task ExecuteAsync(PipelineContext context)

ValidateSteps(allSteps);

var stepsByName = allSteps.ToDictionary(s => s.Name, StringComparer.Ordinal);
var (stepsToExecute, stepsByName) = FilterStepsForExecution(allSteps, context);

// Build dependency graph and execute with readiness-based scheduler
await ExecuteStepsAsTaskDag(allSteps, stepsByName, context).ConfigureAwait(false);
await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false);
}

private static (List<PipelineStep> StepsToExecute, Dictionary<string, PipelineStep> StepsByName) FilterStepsForExecution(
List<PipelineStep> allSteps,
PipelineContext context)
{
var publishingOptions = context.Services.GetService<Microsoft.Extensions.Options.IOptions<Publishing.PublishingOptions>>();
var stepName = publishingOptions?.Value.Step;
var allStepsByName = allSteps.ToDictionary(s => s.Name, StringComparer.Ordinal);

if (string.IsNullOrWhiteSpace(stepName))
{
return (allSteps, allStepsByName);
}

if (!allStepsByName.TryGetValue(stepName, out var targetStep))
{
var availableSteps = string.Join(", ", allSteps.Select(s => $"'{s.Name}'"));
throw new InvalidOperationException(
$"Step '{stepName}' not found in pipeline. Available steps: {availableSteps}");
}

var stepsToExecute = ComputeTransitiveDependencies(targetStep, allStepsByName);
stepsToExecute.Add(targetStep);
var filteredStepsByName = stepsToExecute.ToDictionary(s => s.Name, StringComparer.Ordinal);
return (stepsToExecute, filteredStepsByName);
}

private static List<PipelineStep> ComputeTransitiveDependencies(
PipelineStep step,
Dictionary<string, PipelineStep> stepsByName)
{
var visited = new HashSet<string>(StringComparer.Ordinal);
var result = new List<PipelineStep>();

void Visit(string stepName)
{
if (!visited.Add(stepName))
{
return;
}

if (!stepsByName.TryGetValue(stepName, out var currentStep))
{
return;
}

foreach (var dependency in currentStep.DependsOnSteps)
{
Visit(dependency);
}

result.Add(currentStep);
}

foreach (var dependency in step.DependsOnSteps)
{
Visit(dependency);
}

return result;
}

private static async Task<List<PipelineStep>> CollectStepsFromAnnotationsAsync(PipelineContext context)
Expand Down Expand Up @@ -220,14 +281,16 @@ async Task ExecuteStepWithDependencies(PipelineStep step)
{
try
{
var depTasks = step.DependsOnSteps.Select(depName => stepCompletions[depName].Task);
var depTasks = step.DependsOnSteps
.Where(stepCompletions.ContainsKey)
.Select(depName => stepCompletions[depName].Task);
await Task.WhenAll(depTasks).ConfigureAwait(false);
}
catch (Exception ex)
{
// Find all dependencies that failed
var failedDeps = step.DependsOnSteps
.Where(depName => stepCompletions[depName].Task.IsFaulted)
.Where(depName => stepCompletions.ContainsKey(depName) && stepCompletions[depName].Task.IsFaulted)
.ToList();

var message = failedDeps.Count > 0
Expand Down Expand Up @@ -388,8 +451,7 @@ private static void ValidateDependencyGraph(
{
if (!stepsByName.TryGetValue(requiredByStep, out var requiredByStepObj))
{
throw new InvalidOperationException(
$"Step '{step.Name}' is required by unknown step '{requiredByStep}'");
continue;
}

requiredByStepObj.DependsOnSteps.Add(step.Name);
Expand All @@ -405,7 +467,10 @@ private static void ValidateDependencyGraph(
// DFS to detect cycles
void DetectCycles(string stepName, Stack<string> path)
{
var state = visitStates[stepName];
if (!visitStates.TryGetValue(stepName, out var state))
{
return;
}

if (state == VisitState.Visiting) // Currently visiting - cycle detected!
{
Expand Down
30 changes: 27 additions & 3 deletions src/Aspire.Hosting/Publishing/Publisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,33 @@ await statePathTask.CompleteAsync(
var deployingContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken, options.Value.OutputPath is not null ?
Path.GetFullPath(options.Value.OutputPath) : null);

// Execute the pipeline - it will collect steps from PipelineStepAnnotation on resources
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>();
await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false);
try
{
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>();
await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
Copy link

Copilot AI Oct 22, 2025

Choose a reason for hiding this comment

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

[nitpick] The catch block only handles InvalidOperationException, but other exception types from pipeline execution won't receive user-friendly error reporting. Consider catching a broader exception type or creating a custom exception type specifically for pipeline validation errors to ensure all validation failures are properly reported to users.

Copilot uses AI. Check for mistakes.
{
var errorStep = await progressReporter.CreateStepAsync(
"pipeline-validation",
cancellationToken).ConfigureAwait(false);

await using (errorStep.ConfigureAwait(false))
{
var errorTask = await errorStep.CreateTaskAsync(
"Validating pipeline configuration",
cancellationToken)
.ConfigureAwait(false);

await errorTask.CompleteAsync(
ex.Message,
CompletionState.CompletedWithError,
cancellationToken)
.ConfigureAwait(false);
}

throw;
}
}
else
{
Expand Down
7 changes: 7 additions & 0 deletions src/Aspire.Hosting/Publishing/PublishingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,11 @@ public class PublishingOptions
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public bool ClearCache { get; set; }

/// <summary>
/// Gets or sets the name of a specific deployment step to run.
/// When specified, only this step and its dependencies will be executed.
/// </summary>
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public string? Step { get; set; }
}
Loading