Skip to content

Commit d0fa49c

Browse files
Support executing single step and its dependencies (#12287)
* Support executing single step and its dependencies * Update tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs Co-authored-by: Copilot <[email protected]> * Fix build --------- Co-authored-by: Copilot <[email protected]>
1 parent c2c6eae commit d0fa49c

File tree

6 files changed

+314
-10
lines changed

6 files changed

+314
-10
lines changed

src/Aspire.Cli/Commands/DeployCommand.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace Aspire.Cli.Commands;
1515
internal sealed class DeployCommand : PublishCommandBase
1616
{
1717
private readonly Option<bool> _clearCacheOption;
18+
private readonly Option<string?> _stepOption;
1819

1920
public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
2021
: base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment)
@@ -24,6 +25,12 @@ public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionSer
2425
Description = "Clear the deployment cache associated with the current environment and do not save deployment state"
2526
};
2627
Options.Add(_clearCacheOption);
28+
29+
_stepOption = new Option<string?>("--step")
30+
{
31+
Description = "Run a specific deployment step and its dependencies"
32+
};
33+
Options.Add(_stepOption);
2734
}
2835

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

71+
var step = parseResult.GetValue(_stepOption);
72+
if (step != null)
73+
{
74+
baseArgs.AddRange(["--step", step]);
75+
}
76+
6477
baseArgs.AddRange(unmatchedTokens);
6578

6679
return [.. baseArgs];

src/Aspire.Hosting/DistributedApplicationBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ private void ConfigurePublishingOptions(DistributedApplicationOptions options)
561561
{ "--deploy", "Publishing:Deploy" },
562562
{ "--log-level", "Publishing:LogLevel" },
563563
{ "--clear-cache", "Publishing:ClearCache" },
564+
{ "--step", "Publishing:Step" },
564565
{ "--dcp-cli-path", "DcpPublisher:CliPath" },
565566
{ "--dcp-container-runtime", "DcpPublisher:ContainerRuntime" },
566567
{ "--dcp-dependency-check-timeout", "DcpPublisher:DependencyCheckTimeout" },

src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,71 @@ public async Task ExecuteAsync(PipelineContext context)
115115

116116
ValidateSteps(allSteps);
117117

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

120120
// Build dependency graph and execute with readiness-based scheduler
121-
await ExecuteStepsAsTaskDag(allSteps, stepsByName, context).ConfigureAwait(false);
121+
await ExecuteStepsAsTaskDag(stepsToExecute, stepsByName, context).ConfigureAwait(false);
122+
}
123+
124+
private static (List<PipelineStep> StepsToExecute, Dictionary<string, PipelineStep> StepsByName) FilterStepsForExecution(
125+
List<PipelineStep> allSteps,
126+
PipelineContext context)
127+
{
128+
var publishingOptions = context.Services.GetService<Microsoft.Extensions.Options.IOptions<Publishing.PublishingOptions>>();
129+
var stepName = publishingOptions?.Value.Step;
130+
var allStepsByName = allSteps.ToDictionary(s => s.Name, StringComparer.Ordinal);
131+
132+
if (string.IsNullOrWhiteSpace(stepName))
133+
{
134+
return (allSteps, allStepsByName);
135+
}
136+
137+
if (!allStepsByName.TryGetValue(stepName, out var targetStep))
138+
{
139+
var availableSteps = string.Join(", ", allSteps.Select(s => $"'{s.Name}'"));
140+
throw new InvalidOperationException(
141+
$"Step '{stepName}' not found in pipeline. Available steps: {availableSteps}");
142+
}
143+
144+
var stepsToExecute = ComputeTransitiveDependencies(targetStep, allStepsByName);
145+
stepsToExecute.Add(targetStep);
146+
var filteredStepsByName = stepsToExecute.ToDictionary(s => s.Name, StringComparer.Ordinal);
147+
return (stepsToExecute, filteredStepsByName);
148+
}
149+
150+
private static List<PipelineStep> ComputeTransitiveDependencies(
151+
PipelineStep step,
152+
Dictionary<string, PipelineStep> stepsByName)
153+
{
154+
var visited = new HashSet<string>(StringComparer.Ordinal);
155+
var result = new List<PipelineStep>();
156+
157+
void Visit(string stepName)
158+
{
159+
if (!visited.Add(stepName))
160+
{
161+
return;
162+
}
163+
164+
if (!stepsByName.TryGetValue(stepName, out var currentStep))
165+
{
166+
return;
167+
}
168+
169+
foreach (var dependency in currentStep.DependsOnSteps)
170+
{
171+
Visit(dependency);
172+
}
173+
174+
result.Add(currentStep);
175+
}
176+
177+
foreach (var dependency in step.DependsOnSteps)
178+
{
179+
Visit(dependency);
180+
}
181+
182+
return result;
122183
}
123184

124185
private static async Task<List<PipelineStep>> CollectStepsFromAnnotationsAsync(PipelineContext context)
@@ -220,14 +281,16 @@ async Task ExecuteStepWithDependencies(PipelineStep step)
220281
{
221282
try
222283
{
223-
var depTasks = step.DependsOnSteps.Select(depName => stepCompletions[depName].Task);
284+
var depTasks = step.DependsOnSteps
285+
.Where(stepCompletions.ContainsKey)
286+
.Select(depName => stepCompletions[depName].Task);
224287
await Task.WhenAll(depTasks).ConfigureAwait(false);
225288
}
226289
catch (Exception ex)
227290
{
228291
// Find all dependencies that failed
229292
var failedDeps = step.DependsOnSteps
230-
.Where(depName => stepCompletions[depName].Task.IsFaulted)
293+
.Where(depName => stepCompletions.ContainsKey(depName) && stepCompletions[depName].Task.IsFaulted)
231294
.ToList();
232295

233296
var message = failedDeps.Count > 0
@@ -388,8 +451,7 @@ private static void ValidateDependencyGraph(
388451
{
389452
if (!stepsByName.TryGetValue(requiredByStep, out var requiredByStepObj))
390453
{
391-
throw new InvalidOperationException(
392-
$"Step '{step.Name}' is required by unknown step '{requiredByStep}'");
454+
continue;
393455
}
394456

395457
requiredByStepObj.DependsOnSteps.Add(step.Name);
@@ -405,7 +467,10 @@ private static void ValidateDependencyGraph(
405467
// DFS to detect cycles
406468
void DetectCycles(string stepName, Stack<string> path)
407469
{
408-
var state = visitStates[stepName];
470+
if (!visitStates.TryGetValue(stepName, out var state))
471+
{
472+
return;
473+
}
409474

410475
if (state == VisitState.Visiting) // Currently visiting - cycle detected!
411476
{

src/Aspire.Hosting/Publishing/Publisher.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,33 @@ await statePathTask.CompleteAsync(
183183
var deployingContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken, options.Value.OutputPath is not null ?
184184
Path.GetFullPath(options.Value.OutputPath) : null);
185185

186-
// Execute the pipeline - it will collect steps from PipelineStepAnnotation on resources
187-
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>();
188-
await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false);
186+
try
187+
{
188+
var pipeline = serviceProvider.GetRequiredService<IDistributedApplicationPipeline>();
189+
await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false);
190+
}
191+
catch (InvalidOperationException ex)
192+
{
193+
var errorStep = await progressReporter.CreateStepAsync(
194+
"pipeline-validation",
195+
cancellationToken).ConfigureAwait(false);
196+
197+
await using (errorStep.ConfigureAwait(false))
198+
{
199+
var errorTask = await errorStep.CreateTaskAsync(
200+
"Validating pipeline configuration",
201+
cancellationToken)
202+
.ConfigureAwait(false);
203+
204+
await errorTask.CompleteAsync(
205+
ex.Message,
206+
CompletionState.CompletedWithError,
207+
cancellationToken)
208+
.ConfigureAwait(false);
209+
}
210+
211+
throw;
212+
}
189213
}
190214
else
191215
{

src/Aspire.Hosting/Publishing/PublishingOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,11 @@ public class PublishingOptions
3737
/// </summary>
3838
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
3939
public bool ClearCache { get; set; }
40+
41+
/// <summary>
42+
/// Gets or sets the name of a specific deployment step to run.
43+
/// When specified, only this step and its dependencies will be executed.
44+
/// </summary>
45+
[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
46+
public string? Step { get; set; }
4047
}

0 commit comments

Comments
 (0)