diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index d68d8bdc884..3df5b20173e 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -15,6 +15,7 @@ namespace Aspire.Cli.Commands; internal sealed class DeployCommand : PublishCommandBase { private readonly Option _clearCacheOption; + private readonly Option _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) @@ -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("--step") + { + Description = "Run a specific deployment step and its dependencies" + }; + Options.Add(_stepOption); } protected override string OperationCompletedPrefix => DeployCommandStrings.OperationCompletedPrefix; @@ -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]; diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index eab9d15650b..aec66987b26 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -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" }, diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 2a4405905dd..b14eb02c9d5 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -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 StepsToExecute, Dictionary StepsByName) FilterStepsForExecution( + List allSteps, + PipelineContext context) + { + var publishingOptions = context.Services.GetService>(); + 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 ComputeTransitiveDependencies( + PipelineStep step, + Dictionary stepsByName) + { + var visited = new HashSet(StringComparer.Ordinal); + var result = new List(); + + 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> CollectStepsFromAnnotationsAsync(PipelineContext context) @@ -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 @@ -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); @@ -405,7 +467,10 @@ private static void ValidateDependencyGraph( // DFS to detect cycles void DetectCycles(string stepName, Stack path) { - var state = visitStates[stepName]; + if (!visitStates.TryGetValue(stepName, out var state)) + { + return; + } if (state == VisitState.Visiting) // Currently visiting - cycle detected! { diff --git a/src/Aspire.Hosting/Publishing/Publisher.cs b/src/Aspire.Hosting/Publishing/Publisher.cs index 8479cb3aee8..ad7439a5055 100644 --- a/src/Aspire.Hosting/Publishing/Publisher.cs +++ b/src/Aspire.Hosting/Publishing/Publisher.cs @@ -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(); - await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false); + try + { + var pipeline = serviceProvider.GetRequiredService(); + await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + 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 { diff --git a/src/Aspire.Hosting/Publishing/PublishingOptions.cs b/src/Aspire.Hosting/Publishing/PublishingOptions.cs index 1b01c4b9ee3..ca3e77541f5 100644 --- a/src/Aspire.Hosting/Publishing/PublishingOptions.cs +++ b/src/Aspire.Hosting/Publishing/PublishingOptions.cs @@ -37,4 +37,11 @@ public class PublishingOptions /// [Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public bool ClearCache { get; set; } + + /// + /// Gets or sets the name of a specific deployment step to run. + /// When specified, only this step and its dependencies will be executed. + /// + [Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public string? Step { get; set; } } diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 4db1f7e2bc3..9e6994c3251 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -1699,6 +1699,200 @@ public async Task ExecuteAsync_PipelineLoggerProvider_RespectsPublishingLogLevel } } + [Fact] + public async Task ExecuteAsync_WithNonExistentStepFilter_ThrowsInvalidOperationExceptionWithAvailableSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + builder.Services.Configure(options => + { + options.Step = "non-existent-step"; + }); + + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => await Task.CompletedTask); + pipeline.AddStep("step2", async (context) => await Task.CompletedTask); + pipeline.AddStep("step3", async (context) => await Task.CompletedTask); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Step 'non-existent-step' not found in pipeline", ex.Message); + Assert.Contains("Available steps:", ex.Message); + Assert.Contains("'step1'", ex.Message); + Assert.Contains("'step2'", ex.Message); + Assert.Contains("'step3'", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WithStepFilterAndComplexDependencies_ExecutesTransitiveClosure() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + builder.Services.Configure(options => + { + options.Step = "step5"; + }); + + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep("step1", async (context) => + { + lock (executedSteps) { executedSteps.Add("step1"); } + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + lock (executedSteps) { executedSteps.Add("step2"); } + await Task.CompletedTask; + }); + + pipeline.AddStep("step3", async (context) => + { + lock (executedSteps) { executedSteps.Add("step3"); } + await Task.CompletedTask; + }, dependsOn: "step1"); + + pipeline.AddStep("step4", async (context) => + { + lock (executedSteps) { executedSteps.Add("step4"); } + await Task.CompletedTask; + }, dependsOn: "step2"); + + pipeline.AddStep("step5", async (context) => + { + lock (executedSteps) { executedSteps.Add("step5"); } + await Task.CompletedTask; + }, dependsOn: new[] { "step3", "step4" }); + + pipeline.AddStep("step6", async (context) => + { + lock (executedSteps) { executedSteps.Add("step6"); } + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(5, executedSteps.Count); + Assert.Contains("step1", executedSteps); + Assert.Contains("step2", executedSteps); + Assert.Contains("step3", executedSteps); + Assert.Contains("step4", executedSteps); + Assert.Contains("step5", executedSteps); + Assert.DoesNotContain("step6", executedSteps); + + var step1Index = executedSteps.IndexOf("step1"); + var step2Index = executedSteps.IndexOf("step2"); + var step3Index = executedSteps.IndexOf("step3"); + var step4Index = executedSteps.IndexOf("step4"); + var step5Index = executedSteps.IndexOf("step5"); + + Assert.True(step1Index < step3Index, "step1 should execute before step3"); + Assert.True(step2Index < step4Index, "step2 should execute before step4"); + Assert.True(step3Index < step5Index, "step3 should execute before step5"); + Assert.True(step4Index < step5Index, "step4 should execute before step5"); + } + + [Fact] + public async Task ExecuteAsync_WithStepFilterForIndependentStep_ExecutesOnlyThatStep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + builder.Services.Configure(options => + { + options.Step = "independent-step"; + }); + + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep("step1", async (context) => + { + lock (executedSteps) { executedSteps.Add("step1"); } + await Task.CompletedTask; + }); + + pipeline.AddStep("independent-step", async (context) => + { + lock (executedSteps) { executedSteps.Add("independent-step"); } + await Task.CompletedTask; + }); + + pipeline.AddStep("step3", async (context) => + { + lock (executedSteps) { executedSteps.Add("step3"); } + await Task.CompletedTask; + }, dependsOn: "step1"); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Single(executedSteps); + Assert.Contains("independent-step", executedSteps); + Assert.DoesNotContain("step1", executedSteps); + Assert.DoesNotContain("step3", executedSteps); + } + + [Fact] + public async Task PublishAsync_Deploy_WithInvalidStepName_ReportsErrorWithAvailableSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + builder.Services.Configure(options => + { + options.Step = "invalid-step-name"; + }); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PipelineActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("provision-infra", async (context) => await Task.CompletedTask); + pipeline.AddStep("build-compute", async (context) => await Task.CompletedTask); + pipeline.AddStep("deploy-compute", async (context) => await Task.CompletedTask); + + builder.Services.AddSingleton(pipeline); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + + await Assert.ThrowsAsync(async () => + await publisher.PublishAsync(app.Services.GetRequiredService(), CancellationToken.None)); + + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundErrorActivity = false; + string? errorMessage = null; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + activity.Data.IsError) + { + errorMessage = activity.Data.CompletionMessage; + if (errorMessage != null && + errorMessage.Contains("invalid-step-name") && + errorMessage.Contains("Available steps:") && + errorMessage.Contains("provision-infra") && + errorMessage.Contains("build-compute") && + errorMessage.Contains("deploy-compute")) + { + foundErrorActivity = true; + break; + } + } + } + + Assert.True(foundErrorActivity, $"Expected to find a task activity with detailed error message about invalid step. Got: {errorMessage}"); + } + private sealed class CustomResource(string name) : Resource(name) { }