From f9979f4a26cae0fe0f315d691b463c5a0bda685d Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 8 Oct 2025 18:37:38 -0700 Subject: [PATCH 01/21] Add initial setup for pipelines --- .../AzureDeployingContext.cs | 617 ----------------- .../AzureEnvironmentResource.cs | 647 +++++++++++++++++- .../DistributedApplicationTestingBuilder.cs | 5 + .../DistributedApplicationBuilder.cs | 8 + .../IDistributedApplicationBuilder.cs | 16 +- .../DistributedApplicationPipeline.cs | 198 ++++++ .../IDistributedApplicationPipeline.cs | 37 + .../Pipelines/IPipelineOutputs.cs | 12 + .../Pipelines/IPipelineRegistry.cs | 10 + .../Pipelines/InMemoryPipelineOutputs.cs | 28 + src/Aspire.Hosting/Pipelines/PipelineStep.cs | 38 + .../Pipelines/PipelineStepAnnotation.cs | 27 + .../Pipelines/WellKnownPipelineSteps.cs | 25 + .../Publishing/DeployingContext.cs | 48 ++ src/Aspire.Hosting/Publishing/Publisher.cs | 10 +- .../Publishing/PublishingActivityReporter.cs | 4 +- 16 files changed, 1082 insertions(+), 648 deletions(-) delete mode 100644 src/Aspire.Hosting.Azure/AzureDeployingContext.cs create mode 100644 src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs create mode 100644 src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs create mode 100644 src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs create mode 100644 src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs create mode 100644 src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineStep.cs create mode 100644 src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs create mode 100644 src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs diff --git a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs b/src/Aspire.Hosting.Azure/AzureDeployingContext.cs deleted file mode 100644 index 572de908a6c..00000000000 --- a/src/Aspire.Hosting.Azure/AzureDeployingContext.cs +++ /dev/null @@ -1,617 +0,0 @@ -// 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 ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -using System.Text.Json; -using System.Text.Json.Nodes; -using Aspire.Hosting.Azure.Provisioning; -using Aspire.Hosting.Azure.Provisioning.Internal; -using Aspire.Hosting.Publishing; -using Azure; -using Azure.Core; -using Azure.Identity; -using Aspire.Hosting.ApplicationModel; -using System.Diagnostics.CodeAnalysis; -using Aspire.Hosting.Dcp.Process; -using Microsoft.Extensions.Configuration; - -namespace Aspire.Hosting.Azure; - -internal sealed class AzureDeployingContext( - IProvisioningContextProvider provisioningContextProvider, - IDeploymentStateManager deploymentStateManager, - IBicepProvisioner bicepProvisioner, - IPublishingActivityReporter activityReporter, - IResourceContainerImageBuilder containerImageBuilder, - IProcessRunner processRunner, - IConfiguration configuration, - ITokenCredentialProvider tokenCredentialProvider) -{ - - public async Task DeployModelAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) - { - // Step 0: Validate that Azure CLI is logged in - if (!await TryValidateAzureCliLoginAsync(cancellationToken).ConfigureAwait(false)) - { - return; - } - - var userSecrets = await deploymentStateManager.LoadStateAsync(cancellationToken).ConfigureAwait(false); - var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false); - - // Save deployment state after initializing provisioning context (only if ClearCache is false) - var clearCache = configuration.GetValue("Publishing:ClearCache"); - if (!clearCache) - { - await deploymentStateManager.SaveStateAsync(provisioningContext.DeploymentState, cancellationToken).ConfigureAwait(false); - } - - // Step 1: Provision Azure Bicep resources from the distributed application model - var bicepResources = model.Resources.OfType() - .Where(r => !r.IsExcludedFromPublish()) - .ToList(); - - if (!await TryProvisionAzureBicepResources(bicepResources, provisioningContext, cancellationToken).ConfigureAwait(false)) - { - return; - } - - // Step 2: Build and push container images to ACR - if (!await TryDeployContainerImages(model, cancellationToken).ConfigureAwait(false)) - { - return; - } - - // Step 3: Deploy compute resources to compute environment with images from step 2 - if (!await TryDeployComputeResources(model, provisioningContext, cancellationToken).ConfigureAwait(false)) - { - return; - } - - // Step 4: Save deployment state after successful deployment (only if ClearCache is false) - if (!clearCache) - { - await deploymentStateManager.SaveStateAsync(provisioningContext.DeploymentState, cancellationToken).ConfigureAwait(false); - } - - // Display dashboard URL after successful deployment - var dashboardUrl = TryGetDashboardUrl(model); - if (!string.IsNullOrEmpty(dashboardUrl)) - { - await activityReporter.CompletePublishAsync($"Deployment completed successfully. View Aspire dashboard at {dashboardUrl}", cancellationToken: cancellationToken).ConfigureAwait(false); - } - } - - private async Task TryValidateAzureCliLoginAsync(CancellationToken cancellationToken) - { - // Only do the credential check for the AzureCli that we assume is default - // for deploy scenarios. - if (tokenCredentialProvider.TokenCredential is not AzureCliCredential azureCliCredential) - { - return true; - } - - var validationStep = await activityReporter.CreateStepAsync("validate-auth", cancellationToken).ConfigureAwait(false); - await using (validationStep.ConfigureAwait(false)) - { - try - { - // Test credential by requesting a token for Azure management - var tokenRequest = new TokenRequestContext(["https://management.azure.com/.default"]); - await azureCliCredential.GetTokenAsync(tokenRequest, cancellationToken).ConfigureAwait(false); - - await validationStep.SucceedAsync("Azure CLI authentication validated successfully", cancellationToken).ConfigureAwait(false); - return true; - } - catch (Exception) - { - await validationStep.FailAsync("Azure CLI authentication failed. Please run 'az login' to authenticate before deploying.", cancellationToken).ConfigureAwait(false); - return false; - } - } - } - - private async Task TryProvisionAzureBicepResources(List bicepResources, ProvisioningContext provisioningContext, CancellationToken cancellationToken) - { - bicepResources = bicepResources - .Where(r => r.ProvisioningTaskCompletionSource == null || !r.ProvisioningTaskCompletionSource.Task.IsCompleted) - .ToList(); - - if (bicepResources.Count == 0) - { - return true; - } - - var deployingStep = await activityReporter.CreateStepAsync("deploy-resources", cancellationToken).ConfigureAwait(false); - await using (deployingStep.ConfigureAwait(false)) - { - try - { - var provisioningTasks = new List(); - - foreach (var resource in bicepResources) - { - if (resource is AzureBicepResource bicepResource) - { - var resourceTask = await deployingStep.CreateTaskAsync($"Deploying {resource.Name}", cancellationToken).ConfigureAwait(false); - - var provisioningTask = Task.Run(async () => - { - await using (resourceTask.ConfigureAwait(false)) - { - try - { - bicepResource.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - - if (await bicepProvisioner.ConfigureResourceAsync(configuration, bicepResource, cancellationToken).ConfigureAwait(false)) - { - bicepResource.ProvisioningTaskCompletionSource?.TrySetResult(); - await resourceTask.CompleteAsync($"Using existing deployment for {bicepResource.Name}", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - else - { - await bicepProvisioner.GetOrCreateResourceAsync(bicepResource, provisioningContext, cancellationToken).ConfigureAwait(false); - bicepResource.ProvisioningTaskCompletionSource?.TrySetResult(); - await resourceTask.CompleteAsync($"Successfully provisioned {bicepResource.Name}", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) - { - var errorMessage = ex switch - { - RequestFailedException requestEx => $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}", - _ => $"Deployment failed: {ex.Message}" - }; - await resourceTask.CompleteAsync($"Failed to provision {bicepResource.Name}: {errorMessage}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - bicepResource.ProvisioningTaskCompletionSource?.TrySetException(ex); - throw; - } - } - }, cancellationToken); - - provisioningTasks.Add(provisioningTask); - } - } - - await Task.WhenAll(provisioningTasks).ConfigureAwait(false); - } - catch (Exception) - { - await deployingStep.FailAsync("Failed to deploy Azure resources", cancellationToken: cancellationToken).ConfigureAwait(false); - return false; - } - } - return true; - } - - private async Task TryDeployContainerImages(DistributedApplicationModel model, CancellationToken cancellationToken) - { - var computeResources = model.GetComputeResources().Where(r => r.RequiresImageBuildAndPush()).ToList(); - - if (!computeResources.Any()) - { - return true; - } - - // Generate a deployment-scoped timestamp tag for all resources - var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}"; - foreach (var resource in computeResources) - { - if (resource.TryGetLastAnnotation(out _)) - { - continue; - } - resource.Annotations.Add(new DeploymentImageTagCallbackAnnotation(_ => deploymentTag)); - } - - // Step 1: Build ALL images at once regardless of destination registry - await containerImageBuilder.BuildImagesAsync( - computeResources, - new ContainerBuildOptions - { - TargetPlatform = ContainerTargetPlatform.LinuxAmd64 - }, - cancellationToken).ConfigureAwait(false); - - // Group resources by their deployment target (container registry) since each compute - // environment will provision a different container registry - var resourcesByRegistry = new Dictionary>(); - - foreach (var computeResource in computeResources) - { - if (TryGetContainerRegistry(computeResource, out var registry)) - { - if (!resourcesByRegistry.TryGetValue(registry, out var resourceList)) - { - resourceList = []; - resourcesByRegistry[registry] = resourceList; - } - - resourceList.Add(computeResource); - } - } - - // Step 2: Login to all registries in parallel - await LoginToAllRegistries(resourcesByRegistry.Keys, cancellationToken).ConfigureAwait(false); - - // Step 3: Push images to all registries in parallel - await PushImagesToAllRegistries(resourcesByRegistry, cancellationToken).ConfigureAwait(false); - - return true; - } - - private async Task TryDeployComputeResources(DistributedApplicationModel model, - ProvisioningContext provisioningContext, CancellationToken cancellationToken) - { - var computeResources = model.GetComputeResources().ToList(); - - if (computeResources.Count == 0) - { - return true; - } - - var computeStep = await activityReporter.CreateStepAsync("deploy-compute", cancellationToken).ConfigureAwait(false); - await using (computeStep.ConfigureAwait(false)) - { - try - { - var deploymentTasks = new List(); - - foreach (var computeResource in computeResources) - { - var resourceTask = await computeStep.CreateTaskAsync($"Deploying {computeResource.Name}", cancellationToken).ConfigureAwait(false); - - var deploymentTask = Task.Run(async () => - { - await using (resourceTask.ConfigureAwait(false)) - { - try - { - if (computeResource.GetDeploymentTargetAnnotation() is { } deploymentTarget) - { - if (deploymentTarget.DeploymentTarget is AzureBicepResource bicepResource) - { - await bicepProvisioner.GetOrCreateResourceAsync(bicepResource, provisioningContext, cancellationToken).ConfigureAwait(false); - - var completionMessage = $"Successfully deployed {computeResource.Name}"; - - if (deploymentTarget.ComputeEnvironment is IAzureComputeEnvironmentResource azureComputeEnv) - { - completionMessage += TryGetComputeResourceEndpoint(computeResource, azureComputeEnv); - } - - await resourceTask.CompleteAsync(completionMessage, CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - else - { - await resourceTask.CompleteAsync($"Skipped {computeResource.Name} - no Bicep deployment target", CompletionState.CompletedWithWarning, cancellationToken).ConfigureAwait(false); - } - } - else - { - await resourceTask.CompleteAsync($"Skipped {computeResource.Name} - no deployment target annotation", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) - { - var errorMessage = ex switch - { - RequestFailedException requestEx => $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}", - _ => $"Deployment failed: {ex.Message}" - }; - await resourceTask.CompleteAsync($"Failed to deploy {computeResource.Name}: {errorMessage}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw; - } - } - }, cancellationToken); - - deploymentTasks.Add(deploymentTask); - } - - await Task.WhenAll(deploymentTasks).ConfigureAwait(false); - await computeStep.CompleteAsync($"Successfully deployed {computeResources.Count} compute resources", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - catch (Exception) - { - await computeStep.CompleteAsync($"Compute resource deployment failed", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw; - } - } - - return true; - } - - private static bool TryGetContainerRegistry(IResource computeResource, [NotNullWhen(true)] out IContainerRegistry? containerRegistry) - { - if (computeResource.GetDeploymentTargetAnnotation() is { } deploymentTarget && - deploymentTarget.ContainerRegistry is { } registry) - { - containerRegistry = registry; - return true; - } - - containerRegistry = null; - return false; - } - - private async Task LoginToAllRegistries(IEnumerable registries, CancellationToken cancellationToken) - { - var registryList = registries.ToList(); - if (!registryList.Any()) - { - return; - } - - var loginStep = await activityReporter.CreateStepAsync("auth-registries", cancellationToken).ConfigureAwait(false); - await using (loginStep.ConfigureAwait(false)) - { - try - { - var loginTasks = registryList.Select(async registry => - { - var registryName = await registry.Name.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? - throw new InvalidOperationException("Failed to retrieve container registry information."); - await AuthenticateToAcr(loginStep, registryName, cancellationToken).ConfigureAwait(false); - }); - - await Task.WhenAll(loginTasks).ConfigureAwait(false); - await loginStep.CompleteAsync($"Successfully authenticated to {registryList.Count} container registries", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await loginStep.CompleteAsync($"Failed to authenticate to registries: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw; - } - } - } - - private async Task AuthenticateToAcr(IPublishingStep parentStep, string registryName, CancellationToken cancellationToken) - { - var loginTask = await parentStep.CreateTaskAsync($"Logging in to {registryName}", cancellationToken).ConfigureAwait(false); - var command = BicepCliCompiler.FindFullPathFromPath("az") ?? throw new InvalidOperationException("Failed to find 'az' command"); - await using (loginTask.ConfigureAwait(false)) - { - var loginSpec = new ProcessSpec(command) - { - Arguments = $"acr login --name {registryName}", - ThrowOnNonZeroReturnCode = false - }; - - // Set DOCKER_COMMAND environment variable if using podman - var containerRuntime = GetContainerRuntime(); - if (string.Equals(containerRuntime, "podman", StringComparison.OrdinalIgnoreCase)) - { - loginSpec.EnvironmentVariables["DOCKER_COMMAND"] = "podman"; - } - - var (pendingResult, processDisposable) = processRunner.Run(loginSpec); - await using (processDisposable.ConfigureAwait(false)) - { - var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false); - - if (result.ExitCode != 0) - { - await loginTask.FailAsync($"Login to ACR {registryName} failed with exit code {result.ExitCode}", cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - await loginTask.CompleteAsync($"Successfully logged in to {registryName}", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - } - } - } - - private string? GetContainerRuntime() - { - // Fall back to known config names (primary and legacy) - return configuration["ASPIRE_CONTAINER_RUNTIME"] ?? configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"]; - } - - private async Task PushImagesToAllRegistries(Dictionary> resourcesByRegistry, CancellationToken cancellationToken) - { - var totalImageCount = resourcesByRegistry.Values.SelectMany(resources => resources).Count(); - var pushStep = await activityReporter.CreateStepAsync("push-images", cancellationToken).ConfigureAwait(false); - await using (pushStep.ConfigureAwait(false)) - { - try - { - var allPushTasks = new List(); - - foreach (var (registry, resources) in resourcesByRegistry) - { - var registryName = await registry.Name.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? - throw new InvalidOperationException("Failed to retrieve container registry information."); - - var resourcePushTasks = resources - .Where(r => r.RequiresImageBuildAndPush()) - .Select(async resource => - { - if (!resource.TryGetContainerImageName(out var localImageName)) - { - // For resources without an explicit image name, we use the resource name. - localImageName = resource.Name.ToLowerInvariant(); - } - - IValueProvider cir = new ContainerImageReference(resource); - var targetTag = await cir.GetValueAsync(cancellationToken).ConfigureAwait(false); - - var pushTask = await pushStep.CreateTaskAsync($"Pushing {resource.Name} to {registryName}", cancellationToken).ConfigureAwait(false); - await using (pushTask.ConfigureAwait(false)) - { - try - { - if (targetTag == null) - { - throw new InvalidOperationException($"Failed to get target tag for {resource.Name}"); - } - await TagAndPushImage(localImageName, targetTag, cancellationToken).ConfigureAwait(false); - await pushTask.CompleteAsync($"Successfully pushed {resource.Name} to {targetTag}", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await pushTask.CompleteAsync($"Failed to push {resource.Name}: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw; - } - } - }); - - allPushTasks.AddRange(resourcePushTasks); - } - - await Task.WhenAll(allPushTasks).ConfigureAwait(false); - await pushStep.CompleteAsync($"Successfully pushed {totalImageCount} images to container registries", CompletionState.Completed, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await pushStep.CompleteAsync($"Failed to push images: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false); - throw; - } - } - } - - private async Task TagAndPushImage(string localTag, string targetTag, CancellationToken cancellationToken) - { - await containerImageBuilder.TagImageAsync(localTag, targetTag, cancellationToken).ConfigureAwait(false); - await containerImageBuilder.PushImageAsync(targetTag, cancellationToken).ConfigureAwait(false); - } - - private static string TryGetComputeResourceEndpoint(IResource computeResource, IAzureComputeEnvironmentResource azureComputeEnv) - { - // Check if the compute environment has the default domain output (for Azure Container Apps) - // We could add a reference to AzureContainerAppEnvironmentResource here so we can resolve - // the `ContainerAppDomain` property but we use a string-based lookup here to avoid adding - // explicit references to a compute environment type - if (azureComputeEnv is AzureProvisioningResource provisioningResource && - provisioningResource.Outputs.TryGetValue("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", out var domainValue)) - { - // Only produce endpoints for resources that have external endpoints - if (computeResource.TryGetEndpoints(out var endpoints) && endpoints.Any(e => e.IsExternal)) - { - var endpoint = $"https://{computeResource.Name.ToLowerInvariant()}.{domainValue}"; - return $" to {endpoint}"; - } - } - - return string.Empty; - } - - // This implementation currently assumed that there is only one compute environment - // registered and that it exposes a single dashboard URL. In the future, we may - // need to expand this to support dashboards across compute environments. - private static string? TryGetDashboardUrl(DistributedApplicationModel model) - { - foreach (var resource in model.Resources) - { - if (resource is IAzureComputeEnvironmentResource && - resource is AzureBicepResource environmentBicepResource) - { - // If the resource is a compute environment, we can use its properties - // to construct the dashboard URL. - if (environmentBicepResource.Outputs.TryGetValue($"AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", out var domainValue)) - { - return $"https://aspire-dashboard.ext.{domainValue}"; - } - // If the resource is a compute environment (app service), we can use its properties - // to get the dashboard URL. - if (environmentBicepResource.Outputs.TryGetValue($"AZURE_APP_SERVICE_DASHBOARD_URI", out var dashboardUri)) - { - return (string?)dashboardUri; - } - } - } - - return null; - } - - /// - /// Extracts detailed error information from Azure RequestFailedException responses. - /// Parses the following JSON error structures: - /// 1. Standard Azure error format: { "error": { "code": "...", "message": "...", "details": [...] } } - /// 2. Deployment-specific error format: { "properties": { "error": { "code": "...", "message": "..." } } } - /// 3. Nested error details with recursive parsing for deeply nested error hierarchies - /// - /// The Azure RequestFailedException containing the error response - /// The most specific error message found, or the original exception message if parsing fails - private static string ExtractDetailedErrorMessage(RequestFailedException requestEx) - { - try - { - var response = requestEx.GetRawResponse(); - if (response?.Content is not null) - { - var responseContent = response.Content.ToString(); - if (!string.IsNullOrEmpty(responseContent)) - { - if (JsonNode.Parse(responseContent) is JsonObject responseObj) - { - if (responseObj["error"] is JsonObject errorObj) - { - var code = errorObj["code"]?.ToString(); - var message = errorObj["message"]?.ToString(); - - if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message)) - { - if (errorObj["details"] is JsonArray detailsArray && detailsArray.Count > 0) - { - var deepestErrorMessage = ExtractDeepestErrorMessage(detailsArray); - if (!string.IsNullOrEmpty(deepestErrorMessage)) - { - return deepestErrorMessage; - } - } - - return $"{code}: {message}"; - } - } - - if (responseObj["properties"]?["error"] is JsonObject deploymentErrorObj) - { - var code = deploymentErrorObj["code"]?.ToString(); - var message = deploymentErrorObj["message"]?.ToString(); - - if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message)) - { - return $"{code}: {message}"; - } - } - } - } - } - } - catch (JsonException) { } - - return requestEx.Message; - } - - private static string ExtractDeepestErrorMessage(JsonArray detailsArray) - { - foreach (var detail in detailsArray) - { - if (detail is JsonObject detailObj) - { - var detailCode = detailObj["code"]?.ToString(); - var detailMessage = detailObj["message"]?.ToString(); - - if (detailObj["details"] is JsonArray nestedDetailsArray && nestedDetailsArray.Count > 0) - { - var deeperMessage = ExtractDeepestErrorMessage(nestedDetailsArray); - if (!string.IsNullOrEmpty(deeperMessage)) - { - return deeperMessage; - } - } - - if (!string.IsNullOrEmpty(detailCode) && !string.IsNullOrEmpty(detailMessage)) - { - return $"{detailCode}: {detailMessage}"; - } - } - } - - return string.Empty; - } -} diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index a846dbaece6..c1f796804f3 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -3,12 +3,21 @@ #pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Dcp.Process; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; +using Azure; +using Azure.Core; +using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -49,7 +58,62 @@ public sealed class AzureEnvironmentResource : Resource public AzureEnvironmentResource(string name, ParameterResource location, ParameterResource resourceGroupName, ParameterResource principalId) : base(name) { Annotations.Add(new PublishingCallbackAnnotation(PublishAsync)); - Annotations.Add(new DeployingCallbackAnnotation(DeployAsync)); + + // Register Azure deployment pipeline steps directly + Annotations.Add(new PipelineStepAnnotation(context => + { + var step = new PipelineStep + { + Name = "validate-azure-cli-login", + Action = ctx => ValidateAzureCliLoginAsync(ctx, context) + }; + return step; + })); + + Annotations.Add(new PipelineStepAnnotation(context => + { + var step = new PipelineStep + { + Name = WellKnownPipelineSteps.ProvisionInfrastructure, + Action = ctx => ProvisionAzureBicepResourcesAsync(context.Model, ctx, context) + }; + step.DependsOnStep("validate-azure-cli-login"); + return step; + })); + + Annotations.Add(new PipelineStepAnnotation(context => + { + var step = new PipelineStep + { + Name = WellKnownPipelineSteps.BuildImages, + Action = ctx => BuildContainerImagesAsync(context.Model, ctx, context) + }; + return step; + })); + + Annotations.Add(new PipelineStepAnnotation(context => + { + var step = new PipelineStep + { + Name = "push-container-images", + Action = ctx => PushContainerImagesAsync(context.Model, ctx, context) + }; + step.DependsOnStep(WellKnownPipelineSteps.BuildImages); + return step; + })); + + Annotations.Add(new PipelineStepAnnotation(context => + { + var step = new PipelineStep + { + Name = WellKnownPipelineSteps.DeployCompute, + Action = ctx => DeployComputeResourcesAsync(context.Model, ctx, context) + }; + step.DependsOnStep("push-container-images"); + step.DependsOnStep(WellKnownPipelineSteps.ProvisionInfrastructure); + return step; + })); + Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); Location = location; @@ -70,27 +134,564 @@ private Task PublishAsync(PublishingContext context) return publishingContext.WriteModelAsync(context.Model, this); } - private Task DeployAsync(DeployingContext context) - { - var provisioningContextProvider = context.Services.GetRequiredService(); - var userSecretsManager = context.Services.GetRequiredService(); - var bicepProvisioner = context.Services.GetRequiredService(); - var activityPublisher = context.Services.GetRequiredService(); - var containerImageBuilder = context.Services.GetRequiredService(); - var processRunner = context.Services.GetRequiredService(); - var configuration = context.Services.GetRequiredService(); - var tokenCredentialProvider = context.Services.GetRequiredService(); - - var azureCtx = new AzureDeployingContext( - provisioningContextProvider, - userSecretsManager, - bicepProvisioner, - activityPublisher, - containerImageBuilder, - processRunner, - configuration, - tokenCredentialProvider); - - return azureCtx.DeployModelAsync(context.Model, context.CancellationToken); + private static async Task ValidateAzureCliLoginAsync(DeployingContext context, DeployingContext resourceContext) + { + var tokenCredentialProvider = resourceContext.Services.GetRequiredService(); + + if (tokenCredentialProvider.TokenCredential is not AzureCliCredential azureCliCredential) + { + return; + } + + var validationStep = await context.ActivityReporter + .CreateStepAsync("validate-auth", context.CancellationToken) + .ConfigureAwait(false); + + await using (validationStep.ConfigureAwait(false)) + { + try + { + var tokenRequest = new TokenRequestContext(["https://management.azure.com/.default"]); + await azureCliCredential.GetTokenAsync(tokenRequest, context.CancellationToken) + .ConfigureAwait(false); + + await validationStep.SucceedAsync( + "Azure CLI authentication validated successfully", + context.CancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + await validationStep.FailAsync( + "Azure CLI authentication failed. Please run 'az login' to authenticate before deploying.", + cancellationToken: context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } + + private static async Task ProvisionAzureBicepResourcesAsync( + DistributedApplicationModel model, + DeployingContext context, + DeployingContext resourceContext) + { + var provisioningContextProvider = resourceContext.Services.GetRequiredService(); + var deploymentStateManager = resourceContext.Services.GetRequiredService(); + var bicepProvisioner = resourceContext.Services.GetRequiredService(); + var configuration = resourceContext.Services.GetRequiredService(); + + var userSecrets = await deploymentStateManager.LoadStateAsync(context.CancellationToken) + .ConfigureAwait(false); + var provisioningContext = await provisioningContextProvider + .CreateProvisioningContextAsync(userSecrets, context.CancellationToken) + .ConfigureAwait(false); + + var clearCache = configuration.GetValue("Publishing:ClearCache"); + if (!clearCache) + { + await deploymentStateManager.SaveStateAsync( + provisioningContext.DeploymentState, + context.CancellationToken).ConfigureAwait(false); + } + + context.SetPipelineOutput("ProvisioningContext", provisioningContext); + + var bicepResources = model.Resources.OfType() + .Where(r => !r.IsExcludedFromPublish()) + .Where(r => r.ProvisioningTaskCompletionSource == null || + !r.ProvisioningTaskCompletionSource.Task.IsCompleted) + .ToList(); + + if (bicepResources.Count == 0) + { + return; + } + + var deployingStep = await context.ActivityReporter + .CreateStepAsync("deploy-resources", context.CancellationToken).ConfigureAwait(false); + + await using (deployingStep.ConfigureAwait(false)) + { + var provisioningTasks = bicepResources.Select(async resource => + { + var resourceTask = await deployingStep + .CreateTaskAsync($"Deploying {resource.Name}", context.CancellationToken) + .ConfigureAwait(false); + + await using (resourceTask.ConfigureAwait(false)) + { + try + { + resource.ProvisioningTaskCompletionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + if (await bicepProvisioner.ConfigureResourceAsync( + configuration, resource, context.CancellationToken).ConfigureAwait(false)) + { + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + await resourceTask.CompleteAsync( + $"Using existing deployment for {resource.Name}", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + else + { + await bicepProvisioner.GetOrCreateResourceAsync( + resource, provisioningContext, context.CancellationToken) + .ConfigureAwait(false); + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + await resourceTask.CompleteAsync( + $"Successfully provisioned {resource.Name}", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + var errorMessage = ex switch + { + RequestFailedException requestEx => + $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}", + _ => $"Deployment failed: {ex.Message}" + }; + await resourceTask.CompleteAsync( + $"Failed to provision {resource.Name}: {errorMessage}", + CompletionState.CompletedWithError, + context.CancellationToken).ConfigureAwait(false); + resource.ProvisioningTaskCompletionSource?.TrySetException(ex); + throw; + } + } + }); + + await Task.WhenAll(provisioningTasks).ConfigureAwait(false); + } + + if (!clearCache) + { + await deploymentStateManager.SaveStateAsync( + provisioningContext.DeploymentState, + context.CancellationToken).ConfigureAwait(false); + } + } + + private static async Task BuildContainerImagesAsync( + DistributedApplicationModel model, + DeployingContext context, + DeployingContext resourceContext) + { + var containerImageBuilder = resourceContext.Services.GetRequiredService(); + + var computeResources = model.GetComputeResources() + .Where(r => r.RequiresImageBuildAndPush()) + .ToList(); + + if (!computeResources.Any()) + { + return; + } + + var deploymentTag = $"aspire-deploy-{DateTime.UtcNow:yyyyMMddHHmmss}"; + foreach (var resource in computeResources) + { + if (resource.TryGetLastAnnotation(out _)) + { + continue; + } + resource.Annotations.Add( + new DeploymentImageTagCallbackAnnotation(_ => deploymentTag)); + } + + await containerImageBuilder.BuildImagesAsync( + computeResources, + new ContainerBuildOptions + { + TargetPlatform = ContainerTargetPlatform.LinuxAmd64 + }, + context.CancellationToken).ConfigureAwait(false); + } + + private static async Task PushContainerImagesAsync( + DistributedApplicationModel model, + DeployingContext context, + DeployingContext resourceContext) + { + var containerImageBuilder = resourceContext.Services.GetRequiredService(); + var processRunner = resourceContext.Services.GetRequiredService(); + var configuration = resourceContext.Services.GetRequiredService(); + + var computeResources = model.GetComputeResources() + .Where(r => r.RequiresImageBuildAndPush()) + .ToList(); + + if (!computeResources.Any()) + { + return; + } + + var resourcesByRegistry = new Dictionary>(); + foreach (var computeResource in computeResources) + { + if (TryGetContainerRegistry(computeResource, out var registry)) + { + if (!resourcesByRegistry.TryGetValue(registry, out var resourceList)) + { + resourceList = []; + resourcesByRegistry[registry] = resourceList; + } + resourceList.Add(computeResource); + } + } + + await LoginToAllRegistriesAsync(resourcesByRegistry.Keys, context, processRunner, configuration) + .ConfigureAwait(false); + + await PushImagesToAllRegistriesAsync(resourcesByRegistry, context, containerImageBuilder) + .ConfigureAwait(false); + } + + private static async Task DeployComputeResourcesAsync( + DistributedApplicationModel model, + DeployingContext context, + DeployingContext resourceContext) + { + var bicepProvisioner = resourceContext.Services.GetRequiredService(); + + var provisioningContext = context.GetPipelineOutput("ProvisioningContext"); + var computeResources = model.GetComputeResources().ToList(); + + if (computeResources.Count == 0) + { + return; + } + + var computeStep = await context.ActivityReporter + .CreateStepAsync("deploy-compute", context.CancellationToken) + .ConfigureAwait(false); + + await using (computeStep.ConfigureAwait(false)) + { + var deploymentTasks = computeResources.Select(async computeResource => + { + var resourceTask = await computeStep + .CreateTaskAsync($"Deploying {computeResource.Name}", context.CancellationToken) + .ConfigureAwait(false); + + await using (resourceTask.ConfigureAwait(false)) + { + try + { + if (computeResource.GetDeploymentTargetAnnotation() is { } deploymentTarget) + { + if (deploymentTarget.DeploymentTarget is AzureBicepResource bicepResource) + { + await bicepProvisioner.GetOrCreateResourceAsync( + bicepResource, provisioningContext, context.CancellationToken) + .ConfigureAwait(false); + + var completionMessage = $"Successfully deployed {computeResource.Name}"; + + if (deploymentTarget.ComputeEnvironment is IAzureComputeEnvironmentResource azureComputeEnv) + { + completionMessage += TryGetComputeResourceEndpoint( + computeResource, azureComputeEnv); + } + + await resourceTask.CompleteAsync( + completionMessage, + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + else + { + await resourceTask.CompleteAsync( + $"Skipped {computeResource.Name} - no Bicep deployment target", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + } + } + else + { + await resourceTask.CompleteAsync( + $"Skipped {computeResource.Name} - no deployment target annotation", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + var errorMessage = ex switch + { + RequestFailedException requestEx => + $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}", + _ => $"Deployment failed: {ex.Message}" + }; + await resourceTask.CompleteAsync( + $"Failed to deploy {computeResource.Name}: {errorMessage}", + CompletionState.CompletedWithError, + context.CancellationToken).ConfigureAwait(false); + throw; + } + } + }); + + await Task.WhenAll(deploymentTasks).ConfigureAwait(false); + } + } + + private static bool TryGetContainerRegistry(IResource computeResource, [NotNullWhen(true)] out IContainerRegistry? containerRegistry) + { + if (computeResource.GetDeploymentTargetAnnotation() is { } deploymentTarget && + deploymentTarget.ContainerRegistry is { } registry) + { + containerRegistry = registry; + return true; + } + + containerRegistry = null; + return false; + } + + private static async Task LoginToAllRegistriesAsync(IEnumerable registries, DeployingContext context, IProcessRunner processRunner, IConfiguration configuration) + { + var registryList = registries.ToList(); + if (!registryList.Any()) + { + return; + } + + var loginStep = await context.ActivityReporter.CreateStepAsync("auth-registries", context.CancellationToken).ConfigureAwait(false); + await using (loginStep.ConfigureAwait(false)) + { + try + { + var loginTasks = registryList.Select(async registry => + { + var registryName = await registry.Name.GetValueAsync(context.CancellationToken).ConfigureAwait(false) ?? + throw new InvalidOperationException("Failed to retrieve container registry information."); + await AuthenticateToAcr(loginStep, registryName, context.CancellationToken, processRunner, configuration).ConfigureAwait(false); + }); + + await Task.WhenAll(loginTasks).ConfigureAwait(false); + await loginStep.CompleteAsync($"Successfully authenticated to {registryList.Count} container registries", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await loginStep.CompleteAsync($"Failed to authenticate to registries: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } + + private static async Task AuthenticateToAcr(IPublishingStep parentStep, string registryName, CancellationToken cancellationToken, IProcessRunner processRunner, IConfiguration configuration) + { + var loginTask = await parentStep.CreateTaskAsync($"Logging in to {registryName}", cancellationToken).ConfigureAwait(false); + var command = BicepCliCompiler.FindFullPathFromPath("az") ?? throw new InvalidOperationException("Failed to find 'az' command"); + await using (loginTask.ConfigureAwait(false)) + { + var loginSpec = new ProcessSpec(command) + { + Arguments = $"acr login --name {registryName}", + ThrowOnNonZeroReturnCode = false + }; + + // Set DOCKER_COMMAND environment variable if using podman + var containerRuntime = GetContainerRuntime(configuration); + if (string.Equals(containerRuntime, "podman", StringComparison.OrdinalIgnoreCase)) + { + loginSpec.EnvironmentVariables["DOCKER_COMMAND"] = "podman"; + } + + var (pendingResult, processDisposable) = processRunner.Run(loginSpec); + await using (processDisposable.ConfigureAwait(false)) + { + var result = await pendingResult.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + await loginTask.FailAsync($"Login to ACR {registryName} failed with exit code {result.ExitCode}", cancellationToken: cancellationToken).ConfigureAwait(false); + } + else + { + await loginTask.CompleteAsync($"Successfully logged in to {registryName}", CompletionState.Completed, cancellationToken).ConfigureAwait(false); + } + } + } + } + + private static string? GetContainerRuntime(IConfiguration configuration) + { + // Fall back to known config names (primary and legacy) + return configuration["ASPIRE_CONTAINER_RUNTIME"] ?? configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"]; + } + + private static async Task PushImagesToAllRegistriesAsync(Dictionary> resourcesByRegistry, DeployingContext context, IResourceContainerImageBuilder containerImageBuilder) + { + var totalImageCount = resourcesByRegistry.Values.SelectMany(resources => resources).Count(); + var pushStep = await context.ActivityReporter.CreateStepAsync("push-images", context.CancellationToken).ConfigureAwait(false); + await using (pushStep.ConfigureAwait(false)) + { + try + { + var allPushTasks = new List(); + + foreach (var (registry, resources) in resourcesByRegistry) + { + var registryName = await registry.Name.GetValueAsync(context.CancellationToken).ConfigureAwait(false) ?? + throw new InvalidOperationException("Failed to retrieve container registry information."); + + var resourcePushTasks = resources + .Where(r => r.RequiresImageBuildAndPush()) + .Select(async resource => + { + if (!resource.TryGetContainerImageName(out var localImageName)) + { + localImageName = resource.Name.ToLowerInvariant(); + } + + IValueProvider cir = new ContainerImageReference(resource); + var targetTag = await cir.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + + var pushTask = await pushStep.CreateTaskAsync($"Pushing {resource.Name} to {registryName}", context.CancellationToken).ConfigureAwait(false); + await using (pushTask.ConfigureAwait(false)) + { + try + { + if (targetTag == null) + { + throw new InvalidOperationException($"Failed to get target tag for {resource.Name}"); + } + await TagAndPushImage(localImageName, targetTag, context.CancellationToken, containerImageBuilder).ConfigureAwait(false); + await pushTask.CompleteAsync($"Successfully pushed {resource.Name} to {targetTag}", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await pushTask.CompleteAsync($"Failed to push {resource.Name}: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + throw; + } + } + }); + + allPushTasks.AddRange(resourcePushTasks); + } + + await Task.WhenAll(allPushTasks).ConfigureAwait(false); + await pushStep.CompleteAsync($"Successfully pushed {totalImageCount} images to container registries", CompletionState.Completed, context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await pushStep.CompleteAsync($"Failed to push images: {ex.Message}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); + throw; + } + } + } + + private static async Task TagAndPushImage(string localTag, string targetTag, CancellationToken cancellationToken, IResourceContainerImageBuilder containerImageBuilder) + { + await containerImageBuilder.TagImageAsync(localTag, targetTag, cancellationToken).ConfigureAwait(false); + await containerImageBuilder.PushImageAsync(targetTag, cancellationToken).ConfigureAwait(false); + } + + private static string TryGetComputeResourceEndpoint(IResource computeResource, IAzureComputeEnvironmentResource azureComputeEnv) + { + // Check if the compute environment has the default domain output (for Azure Container Apps) + // We could add a reference to AzureContainerAppEnvironmentResource here so we can resolve + // the `ContainerAppDomain` property but we use a string-based lookup here to avoid adding + // explicit references to a compute environment type + if (azureComputeEnv is AzureProvisioningResource provisioningResource && + provisioningResource.Outputs.TryGetValue("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", out var domainValue)) + { + var endpoint = $"https://{computeResource.Name.ToLowerInvariant()}.{domainValue}"; + return $" to {endpoint}"; + } + + return string.Empty; + } + + /// + /// Extracts detailed error information from Azure RequestFailedException responses. + /// Parses the following JSON error structures: + /// 1. Standard Azure error format: { "error": { "code": "...", "message": "...", "details": [...] } } + /// 2. Deployment-specific error format: { "properties": { "error": { "code": "...", "message": "..." } } } + /// 3. Nested error details with recursive parsing for deeply nested error hierarchies + /// + /// The Azure RequestFailedException containing the error response + /// The most specific error message found, or the original exception message if parsing fails + private static string ExtractDetailedErrorMessage(RequestFailedException requestEx) + { + try + { + var response = requestEx.GetRawResponse(); + if (response?.Content is not null) + { + var responseContent = response.Content.ToString(); + if (!string.IsNullOrEmpty(responseContent)) + { + if (JsonNode.Parse(responseContent) is JsonObject responseObj) + { + if (responseObj["error"] is JsonObject errorObj) + { + var code = errorObj["code"]?.ToString(); + var message = errorObj["message"]?.ToString(); + + if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message)) + { + if (errorObj["details"] is JsonArray detailsArray && detailsArray.Count > 0) + { + var deepestErrorMessage = ExtractDeepestErrorMessage(detailsArray); + if (!string.IsNullOrEmpty(deepestErrorMessage)) + { + return deepestErrorMessage; + } + } + + return $"{code}: {message}"; + } + } + + if (responseObj["properties"]?["error"] is JsonObject deploymentErrorObj) + { + var code = deploymentErrorObj["code"]?.ToString(); + var message = deploymentErrorObj["message"]?.ToString(); + + if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message)) + { + return $"{code}: {message}"; + } + } + } + } + } + } + catch (JsonException) { } + + return requestEx.Message; + } + + private static string ExtractDeepestErrorMessage(JsonArray detailsArray) + { + foreach (var detail in detailsArray) + { + if (detail is JsonObject detailObj) + { + var detailCode = detailObj["code"]?.ToString(); + var detailMessage = detailObj["message"]?.ToString(); + + if (detailObj["details"] is JsonArray nestedDetailsArray && nestedDetailsArray.Count > 0) + { + var deeperMessage = ExtractDeepestErrorMessage(nestedDetailsArray); + if (!string.IsNullOrEmpty(deeperMessage)) + { + return deeperMessage; + } + } + + if (!string.IsNullOrEmpty(detailCode) && !string.IsNullOrEmpty(detailMessage)) + { + return $"{detailCode}: {detailMessage}"; + } + } + } + + return string.Empty; } } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index c93c4cdfcbc..db806cb9759 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -5,6 +5,7 @@ using System.Reflection; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Eventing; +using Aspire.Hosting.Pipelines; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -238,6 +239,8 @@ private sealed class Builder(SuspendingDistributedApplicationFactory factory, Di public IDistributedApplicationEventing Eventing => innerBuilder.Eventing; + public IDistributedApplicationPipeline Pipeline => innerBuilder.Pipeline; + public IResourceBuilder AddResource(T resource) where T : IResource => innerBuilder.AddResource(resource); public DistributedApplication Build() => BuildAsync(CancellationToken.None).Result; @@ -388,6 +391,8 @@ static Assembly FindApplicationAssembly() public IDistributedApplicationEventing Eventing => _innerBuilder.Eventing; + public IDistributedApplicationPipeline Pipeline => _innerBuilder.Pipeline; + public IResourceBuilder AddResource(T resource) where T : IResource => _innerBuilder.AddResource(resource); [MemberNotNull(nameof(_app))] diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 7c6d6028baa..b3e403a1b9d 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -12,6 +12,7 @@ using Aspire.Hosting.Cli; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Devcontainers; using Aspire.Hosting.Devcontainers.Codespaces; using Aspire.Hosting.Eventing; @@ -58,6 +59,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder private readonly DistributedApplicationOptions _options; private readonly HostApplicationBuilder _innerBuilder; + private IDistributedApplicationPipeline? _pipeline; /// public IHostEnvironment Environment => _innerBuilder.Environment; @@ -86,6 +88,10 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder /// public IDistributedApplicationEventing Eventing { get; } = new DistributedApplicationEventing(); + /// + public IDistributedApplicationPipeline Pipeline => + _pipeline ??= new DistributedApplicationPipeline(this); + /// /// Initializes a new instance of the class with the specified options. /// @@ -253,6 +259,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Core things _innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources)); + _innerBuilder.Services.AddSingleton(this); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); @@ -412,6 +419,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); + _innerBuilder.Services.AddSingleton(); // Register IDeploymentStateManager based on execution context if (ExecutionContext.IsPublishMode) diff --git a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs index df4a7d3c91f..ca89926b81a 100644 --- a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs @@ -3,6 +3,7 @@ using System.Reflection; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using Microsoft.Extensions.Configuration; @@ -38,7 +39,7 @@ namespace Aspire.Hosting; /// methods are used to add Redis and PostgreSQL container resources. The results of the methods are stored in variables for /// later use. /// -/// +/// /// /// var builder = DistributedApplication.CreateBuilder(args); /// var cache = builder.AddRedis("cache"); @@ -106,7 +107,7 @@ public interface IDistributedApplicationBuilder /// context.EnvironmentVariables["RABBITMQ_NODENAME"] = nodeName; /// }); /// } - /// + /// /// return builder; /// } /// @@ -122,6 +123,15 @@ public interface IDistributedApplicationBuilder /// public IResourceCollection Resources { get; } + /// + /// Gets the deployment pipeline for this distributed application. + /// + /// + /// The pipeline allows adding custom deployment steps that execute during the deploy process. + /// Steps can declare dependencies on other steps to control execution order. + /// + public IDistributedApplicationPipeline Pipeline { get; } + /// /// Adds a resource of type to the distributed application. /// @@ -201,7 +211,7 @@ public interface IDistributedApplicationBuilder /// }, /// secret: true, /// connectionString: true); - /// + /// /// var surrogate = new ConnectionStringParameterResource(parameterBuilder.Resource, environmentVariableName); /// return builder.CreateResourceBuilder(surrogate); /// } diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs new file mode 100644 index 00000000000..8d58221fb4d --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -0,0 +1,198 @@ +// 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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +internal sealed class DistributedApplicationPipeline : IDistributedApplicationPipeline +{ + private readonly List _steps = new(); + private readonly IDistributedApplicationBuilder _builder; + + public DistributedApplicationPipeline(IDistributedApplicationBuilder builder) + { + _builder = builder; + } + + public void AddStep(string name, + Func action, + string? dependsOn = null) + { + var step = new PipelineStep + { + Name = name, + Action = action + }; + + if (dependsOn != null) + { + step.DependsOnStep(dependsOn); + } + + _steps.Add(step); + } + + public void AddStep(PipelineStep step) + { + _steps.Add(step); + } + + public async Task ExecuteAsync(DeployingContext context) + { + var allSteps = _steps.Concat(CollectAnnotatedSteps(context)).ToList(); + + if (allSteps.Count == 0) + { + return; + } + + ValidateSteps(allSteps); + + var registry = new PipelineRegistry(allSteps); + + var levels = ResolveDependencies(allSteps, registry); + + foreach (var level in levels) + { + await Task.WhenAll(level.Select(step => + ExecuteStepAsync(step, context))).ConfigureAwait(false); + } + } + + private static IEnumerable CollectAnnotatedSteps(DeployingContext context) + { + foreach (var resource in context.Model.Resources) + { + var annotations = resource.Annotations + .OfType(); + + foreach (var annotation in annotations) + { + yield return annotation.CreateStep(context); + } + } + } + + private static void ValidateSteps(IEnumerable steps) + { + var stepNames = new HashSet(); + + foreach (var step in steps) + { + if (!stepNames.Add(step.Name)) + { + throw new InvalidOperationException( + $"Duplicate step name: '{step.Name}'"); + } + } + + foreach (var step in steps) + { + foreach (var dependency in step.Dependencies) + { + if (!stepNames.Contains(dependency)) + { + throw new InvalidOperationException( + $"Step '{step.Name}' depends on unknown step '{dependency}'"); + } + } + } + } + + private static List> ResolveDependencies( + IEnumerable steps, + IPipelineRegistry registry) + { + var graph = new Dictionary>(); + var inDegree = new Dictionary(); + + foreach (var step in steps) + { + graph[step.Name] = []; + inDegree[step.Name] = 0; + } + + foreach (var step in steps) + { + foreach (var dependency in step.Dependencies) + { + if (!graph.TryGetValue(dependency, out var dependents)) + { + throw new InvalidOperationException( + $"Step '{step.Name}' depends on unknown step '{dependency}'"); + } + + dependents.Add(step.Name); + inDegree[step.Name]++; + } + } + + var levels = new List>(); + var queue = new Queue( + inDegree.Where(kvp => kvp.Value == 0).Select(kvp => kvp.Key) + ); + + while (queue.Count > 0) + { + var currentLevel = new List(); + var levelSize = queue.Count; + + for (var i = 0; i < levelSize; i++) + { + var stepName = queue.Dequeue(); + var step = registry.GetStep(stepName)!; + currentLevel.Add(step); + + foreach (var dependent in graph[stepName]) + { + inDegree[dependent]--; + if (inDegree[dependent] == 0) + { + queue.Enqueue(dependent); + } + } + } + + levels.Add(currentLevel); + } + + if (levels.Sum(l => l.Count) != steps.Count()) + { + throw new InvalidOperationException( + "Circular dependency detected in pipeline steps"); + } + + return levels; + } + + private static async Task ExecuteStepAsync(PipelineStep step, DeployingContext context) + { + try + { + await step.Action(context).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Step '{step.Name}' failed: {ex.Message}", ex); + } + } + + private sealed class PipelineRegistry : IPipelineRegistry + { + private readonly Dictionary _stepsByName; + + public PipelineRegistry(IEnumerable steps) + { + _stepsByName = steps.ToDictionary(s => s.Name); + } + + public IEnumerable GetAllSteps() => _stepsByName.Values; + + public PipelineStep? GetStep(string name) => + _stepsByName.TryGetValue(name, out var step) ? step : null; + } +} diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs new file mode 100644 index 00000000000..bbb7ee3487c --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -0,0 +1,37 @@ +// 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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents a pipeline for executing deployment steps in a distributed application. +/// +public interface IDistributedApplicationPipeline +{ + /// + /// Adds a deployment step to the pipeline. + /// + /// The unique name of the step. + /// The action to execute for this step. + /// The name of the step this step depends on, if any. + void AddStep(string name, + Func action, + string? dependsOn = null); + + /// + /// Adds a deployment step to the pipeline. + /// + /// The pipeline step to add. + void AddStep(PipelineStep step); + + /// + /// Executes all steps in the pipeline in dependency order. + /// + /// The deploying context for the execution. + /// A task representing the asynchronous operation. + Task ExecuteAsync(DeployingContext context); +} diff --git a/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs b/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs new file mode 100644 index 00000000000..ea42852e923 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs @@ -0,0 +1,12 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +internal interface IPipelineOutputs +{ + void Set(string key, T value); + bool TryGet(string key, [NotNullWhen(true)] out T? value); +} diff --git a/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs b/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs new file mode 100644 index 00000000000..b4172b69ed1 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines; + +internal interface IPipelineRegistry +{ + IEnumerable GetAllSteps(); + PipelineStep? GetStep(string name); +} diff --git a/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs b/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs new file mode 100644 index 00000000000..ce1d69b8b26 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs @@ -0,0 +1,28 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +internal sealed class InMemoryPipelineOutputs : IPipelineOutputs +{ + private readonly Dictionary _outputs = new(); + + public void Set(string key, T value) + { + ArgumentNullException.ThrowIfNull(value); + _outputs[key] = value; + } + + public bool TryGet(string key, [NotNullWhen(true)] out T? value) + { + if (_outputs.TryGetValue(key, out var obj) && obj is T typed) + { + value = typed; + return true; + } + value = default; + return false; + } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs new file mode 100644 index 00000000000..2b8fbe51338 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -0,0 +1,38 @@ +// 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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents a step in the deployment pipeline. +/// +public class PipelineStep +{ + /// + /// Gets or initializes the unique name of the step. + /// + public required string Name { get; init; } + + /// + /// Gets or initializes the action to execute for this step. + /// + public required Func Action { get; init; } + + /// + /// Gets the list of step names that this step depends on. + /// + public List Dependencies { get; } = []; + + /// + /// Adds a dependency on another step. + /// + /// The name of the step to depend on. + public void DependsOnStep(string stepName) + { + Dependencies.Add(stepName); + } +} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs new file mode 100644 index 00000000000..e0639b2195f --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs @@ -0,0 +1,27 @@ +// 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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Pipelines; + +/// +/// An annotation that creates a pipeline step for a resource during deployment. +/// +/// +/// Initializes a new instance of the class. +/// +/// A factory function that creates the pipeline step. +public class PipelineStepAnnotation(Func factory) : IResourceAnnotation +{ + private readonly Func _factory = factory; + + /// + /// Creates a pipeline step using the provided deploying context. + /// + /// The deploying context. + /// The created pipeline step. + public PipelineStep CreateStep(DeployingContext context) => _factory(context); +} diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs new file mode 100644 index 00000000000..be5ee0b4329 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Pipelines; + +/// +/// Defines well-known pipeline step names used in the deployment process. +/// +public static class WellKnownPipelineSteps +{ + /// + /// The step that provisions infrastructure resources. + /// + public const string ProvisionInfrastructure = "provision-infra"; + + /// + /// The step that builds container images. + /// + public const string BuildImages = "build-images"; + + /// + /// The step that deploys to compute infrastructure. + /// + public const string DeployCompute = "deploy-compute"; +} diff --git a/src/Aspire.Hosting/Publishing/DeployingContext.cs b/src/Aspire.Hosting/Publishing/DeployingContext.cs index 6b4caf02811..46d4b496a3e 100644 --- a/src/Aspire.Hosting/Publishing/DeployingContext.cs +++ b/src/Aspire.Hosting/Publishing/DeployingContext.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -27,6 +28,7 @@ public sealed class DeployingContext( string? outputPath) { private IPublishingActivityReporter? _activityReporter; + private IPipelineOutputs? _pipelineOutputs; /// /// Gets the distributed application model to be deployed. @@ -64,6 +66,52 @@ public sealed class DeployingContext( /// public string? OutputPath { get; } = outputPath; + /// + /// Gets the pipeline outputs manager for passing data between pipeline steps. + /// + internal IPipelineOutputs PipelineOutputs => _pipelineOutputs ??= + Services.GetService() ?? new InMemoryPipelineOutputs(); + + /// + /// Sets an output value that can be consumed by dependent pipeline steps. + /// + /// The type of the output value. + /// The key to identify the output. + /// The value to store. + public void SetPipelineOutput(string key, T value) + { + PipelineOutputs.Set(key, value); + } + + /// + /// Attempts to retrieve an output value set by a previous pipeline step. + /// + /// The expected type of the output value. + /// The key identifying the output. + /// The retrieved value if found. + /// True if the output was found and is of the expected type; otherwise, false. + public bool TryGetPipelineOutput(string key, [NotNullWhen(true)] out T? value) + { + return PipelineOutputs.TryGet(key, out value); + } + + /// + /// Retrieves an output value set by a previous pipeline step. + /// + /// The expected type of the output value. + /// The key identifying the output. + /// The output value. + /// Thrown when the output is not found or is not of the expected type. + public T GetPipelineOutput(string key) + { + if (!TryGetPipelineOutput(key, out var value)) + { + throw new InvalidOperationException( + $"Pipeline output '{key}' not found or is not of type {typeof(T).Name}"); + } + return value; + } + /// /// Invokes deploying callbacks for each resource in the provided distributed application model. /// diff --git a/src/Aspire.Hosting/Publishing/Publisher.cs b/src/Aspire.Hosting/Publishing/Publisher.cs index 54880d8e53f..39592e24b2c 100644 --- a/src/Aspire.Hosting/Publishing/Publisher.cs +++ b/src/Aspire.Hosting/Publishing/Publisher.cs @@ -5,6 +5,7 @@ #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -84,7 +85,7 @@ public async Task PublishAsync(DistributedApplicationModel model, CancellationTo { if (options.Value.Deploy) { - if (resource.HasAnnotationOfType()) + if (resource.HasAnnotationOfType()) { targetResources.Add(resource); } @@ -133,7 +134,7 @@ await statePathTask.CompleteAsync( } } - // If deployment is enabled, run deploying callbacks after publishing + // If deployment is enabled, execute the pipeline with steps from PipelineStepAnnotation if (options.Value.Deploy) { // Initialize parameters as a pre-requisite for deployment @@ -142,7 +143,10 @@ await statePathTask.CompleteAsync( var deployingContext = new DeployingContext(model, executionContext, serviceProvider, logger, cancellationToken, options.Value.OutputPath is not null ? Path.GetFullPath(options.Value.OutputPath) : null); - await deployingContext.WriteModelAsync(model).ConfigureAwait(false); + + // Execute the pipeline - it will collect steps from PipelineStepAnnotation on resources + var builder = serviceProvider.GetRequiredService(); + await builder.Pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false); } else { diff --git a/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs b/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs index 1177779b576..5de6682a8dd 100644 --- a/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs +++ b/src/Aspire.Hosting/Publishing/PublishingActivityReporter.cs @@ -249,7 +249,7 @@ private async Task SubscribeToInteractionsAsync(CancellationToken cancellationTo { await foreach (var interaction in _interactionService.SubscribeInteractionUpdates(cancellationToken).ConfigureAwait(false)) { - await HandleInteractionUpdateAsync(interaction, cancellationToken).ConfigureAwait(false); + await WriteInteractionUpdateToClientAsync(interaction, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -258,7 +258,7 @@ private async Task SubscribeToInteractionsAsync(CancellationToken cancellationTo } } - private async Task HandleInteractionUpdateAsync(Interaction interaction, CancellationToken cancellationToken) + private async Task WriteInteractionUpdateToClientAsync(Interaction interaction, CancellationToken cancellationToken) { if (interaction.State == Interaction.InteractionState.InProgress) { From 07679a3e98a4cae3b2c584d90e3852b64bb69d20 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 08:04:06 -0700 Subject: [PATCH 02/21] Support emitting multiple steps from PipelineStepAnnotation --- .../AzureEnvironmentResource.cs | 38 ++++++------------- src/Aspire.Hosting/Pipelines/PipelineStep.cs | 9 +++++ .../Pipelines/PipelineStepAnnotation.cs | 32 +++++++++++----- 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index c1f796804f3..7035ed136f6 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -59,59 +59,43 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet { Annotations.Add(new PublishingCallbackAnnotation(PublishAsync)); - // Register Azure deployment pipeline steps directly Annotations.Add(new PipelineStepAnnotation(context => { - var step = new PipelineStep + var validateStep = new PipelineStep { Name = "validate-azure-cli-login", Action = ctx => ValidateAzureCliLoginAsync(ctx, context) }; - return step; - })); - Annotations.Add(new PipelineStepAnnotation(context => - { - var step = new PipelineStep + var provisionStep = new PipelineStep { Name = WellKnownPipelineSteps.ProvisionInfrastructure, Action = ctx => ProvisionAzureBicepResourcesAsync(context.Model, ctx, context) }; - step.DependsOnStep("validate-azure-cli-login"); - return step; - })); + provisionStep.DependsOnStep(validateStep); - Annotations.Add(new PipelineStepAnnotation(context => - { - var step = new PipelineStep + var buildStep = new PipelineStep { Name = WellKnownPipelineSteps.BuildImages, Action = ctx => BuildContainerImagesAsync(context.Model, ctx, context) }; - return step; - })); - Annotations.Add(new PipelineStepAnnotation(context => - { - var step = new PipelineStep + var pushStep = new PipelineStep { Name = "push-container-images", Action = ctx => PushContainerImagesAsync(context.Model, ctx, context) }; - step.DependsOnStep(WellKnownPipelineSteps.BuildImages); - return step; - })); + pushStep.DependsOnStep(buildStep); - Annotations.Add(new PipelineStepAnnotation(context => - { - var step = new PipelineStep + var deployStep = new PipelineStep { Name = WellKnownPipelineSteps.DeployCompute, Action = ctx => DeployComputeResourcesAsync(context.Model, ctx, context) }; - step.DependsOnStep("push-container-images"); - step.DependsOnStep(WellKnownPipelineSteps.ProvisionInfrastructure); - return step; + deployStep.DependsOnStep(pushStep); + deployStep.DependsOnStep(provisionStep); + + return new[] { validateStep, provisionStep, buildStep, pushStep, deployStep }; })); Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index 2b8fbe51338..7721493f508 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -35,4 +35,13 @@ public void DependsOnStep(string stepName) { Dependencies.Add(stepName); } + + /// + /// Adds a dependency on another step. + /// + /// The step to depend on. + public void DependsOnStep(PipelineStep step) + { + Dependencies.Add(step.Name); + } } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs index e0639b2195f..68c3e14f8ae 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs @@ -10,18 +10,32 @@ namespace Aspire.Hosting.Pipelines; /// /// An annotation that creates a pipeline step for a resource during deployment. /// -/// -/// Initializes a new instance of the class. -/// -/// A factory function that creates the pipeline step. -public class PipelineStepAnnotation(Func factory) : IResourceAnnotation +public class PipelineStepAnnotation : IResourceAnnotation { - private readonly Func _factory = factory; + private readonly Func> _factory; /// - /// Creates a pipeline step using the provided deploying context. + /// Initializes a new instance of the class. + /// + /// A factory function that creates the pipeline step. + public PipelineStepAnnotation(Func factory) + { + _factory = context => [factory(context)]; + } + + /// + /// Initializes a new instance of the class with a factory that creates multiple pipeline steps. + /// + /// A factory function that creates multiple pipeline steps. + public PipelineStepAnnotation(Func> factory) + { + _factory = factory; + } + + /// + /// Creates pipeline steps using the provided deploying context. /// /// The deploying context. - /// The created pipeline step. - public PipelineStep CreateStep(DeployingContext context) => _factory(context); + /// The created pipeline steps. + public IEnumerable CreateSteps(DeployingContext context) => _factory(context); } From 505ac46614d4ccfebf1649bfed85bc998ed6d9d7 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 15:28:50 -0700 Subject: [PATCH 03/21] Add sample app with bind mount upload scenario --- Aspire.slnx | 5 + Directory.Packages.props | 1 + .../pipelines/Pipelines.AppHost/AppHost.cs | 358 ++++++++++++++++++ .../Pipelines.AppHost/Dockerfile.bindmount | 20 + .../Pipelines.AppHost.csproj | 24 ++ .../Properties/launchSettings.json | 33 ++ .../appsettings.Development.json | 8 + .../Pipelines.AppHost/appsettings.json | 9 + ...istributedApplicationPipelineExtensions.cs | 11 + .../Pipelines.Library.csproj | 13 + playground/pipelines/data/sample.txt | 51 +++ .../AzureEnvironmentResource.cs | 1 + .../DistributedApplicationPipeline.cs | 12 +- .../IDistributedApplicationPipeline.cs | 4 +- src/Aspire.Hosting/Pipelines/PipelineStep.cs | 23 ++ 15 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 playground/pipelines/Pipelines.AppHost/AppHost.cs create mode 100644 playground/pipelines/Pipelines.AppHost/Dockerfile.bindmount create mode 100644 playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj create mode 100644 playground/pipelines/Pipelines.AppHost/Properties/launchSettings.json create mode 100644 playground/pipelines/Pipelines.AppHost/appsettings.Development.json create mode 100644 playground/pipelines/Pipelines.AppHost/appsettings.json create mode 100644 playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs create mode 100644 playground/pipelines/Pipelines.Library/Pipelines.Library.csproj create mode 100644 playground/pipelines/data/sample.txt diff --git a/Aspire.slnx b/Aspire.slnx index fc0f7ab93a6..e4d0434187a 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -256,6 +256,10 @@ + + + + @@ -349,6 +353,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index e819b38e224..47b7b8dcbe2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs new file mode 100644 index 00000000000..52fecbeca08 --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs @@ -0,0 +1,358 @@ +#pragma warning disable ASPIREPUBLISHERS001 + +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Azure.Identity; +using Azure.Provisioning; +using Azure.Provisioning.Storage; +using Azure.Storage.Files.Shares; +using Azure.Storage.Files.Shares.Models; + +var builder = DistributedApplication.CreateBuilder(args); + +var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env") + .ConfigureInfrastructure(infra => + { + var volumeStorageAccount = infra.GetProvisionableResources().OfType().SingleOrDefault(); + if (volumeStorageAccount == null) + { + return; + } + infra.Add(new ProvisioningOutput("STORAGE_VOLUME_ACCOUNT_NAME", typeof(string)) + { + Value = volumeStorageAccount.Name + }); + var fileShares = infra.GetProvisionableResources().OfType().ToList(); + for (var i = 0; i < fileShares.Count; i++) + { + var fileShare = fileShares[i]; + infra.Add(new ProvisioningOutput($"SHARES_{i}_NAME", typeof(string)) + { + Value = fileShare.Name + }); + } + }); + +var withBindMount = builder.AddDockerfile("withBindMount", ".", "./Dockerfile.bindmount") + .WithBindMount("../data", "/data"); + +// This step could also be modeled as a Bicep resource with the role assignment +// for the principalId associated with the deployment. +builder.Pipeline.AddStep("assign-storage-role", async (context) => +{ + var resourcesWithBindMounts = context.Model.Resources + .Where(r => r.TryGetContainerMounts(out var mounts) && + mounts.Any(m => m.Type == ContainerMountType.BindMount)) + .ToList(); + + if (resourcesWithBindMounts.Count == 0) + { + return; + } + + var targetEnv = acaEnv.Resource; + var storageAccountName = targetEnv.Outputs["storagE_VOLUME_ACCOUNT_NAME"]?.ToString(); + + if (string.IsNullOrEmpty(storageAccountName)) + { + return; + } + + var roleAssignmentStep = await context.ActivityReporter + .CreateStepAsync($"assign-storage-role", context.CancellationToken) + .ConfigureAwait(false); + + await using (roleAssignmentStep.ConfigureAwait(false)) + { + var assignRoleTask = await roleAssignmentStep + .CreateTaskAsync($"Granting file share access to current user", context.CancellationToken) + .ConfigureAwait(false); + + await using (assignRoleTask.ConfigureAwait(false)) + { + try + { + // Get the current signed-in user's object ID + var getUserProcess = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = "ad signed-in-user show --query id -o tsv", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (getUserProcess == null) + { + await assignRoleTask.CompleteAsync( + "Failed to start az CLI process", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + var userObjectId = await getUserProcess.StandardOutput.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + userObjectId = userObjectId.Trim(); + + await getUserProcess.WaitForExitAsync(context.CancellationToken).ConfigureAwait(false); + + if (getUserProcess.ExitCode != 0) + { + var error = await getUserProcess.StandardError.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + await assignRoleTask.CompleteAsync( + $"Failed to get signed-in user: {error}", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + // Get the current subscription ID + var getSubscriptionProcess = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = "account show --query id -o tsv", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (getSubscriptionProcess == null) + { + await assignRoleTask.CompleteAsync( + "Failed to get subscription ID", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + var subscriptionId = await getSubscriptionProcess.StandardOutput.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + subscriptionId = subscriptionId.Trim(); + + await getSubscriptionProcess.WaitForExitAsync(context.CancellationToken).ConfigureAwait(false); + + if (getSubscriptionProcess.ExitCode != 0) + { + var error = await getSubscriptionProcess.StandardError.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + await assignRoleTask.CompleteAsync( + $"Failed to get subscription ID: {error}", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + // Get the resource group for the storage account + var getResourceGroupProcess = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"storage account show --name {storageAccountName} --query resourceGroup -o tsv", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (getResourceGroupProcess == null) + { + await assignRoleTask.CompleteAsync( + "Failed to get resource group", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + var resourceGroup = await getResourceGroupProcess.StandardOutput.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + resourceGroup = resourceGroup.Trim(); + + await getResourceGroupProcess.WaitForExitAsync(context.CancellationToken).ConfigureAwait(false); + + if (getResourceGroupProcess.ExitCode != 0) + { + var error = await getResourceGroupProcess.StandardError.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + await assignRoleTask.CompleteAsync( + $"Failed to get resource group: {error}", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + // Build the scope for the storage account + var scope = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}"; + + // Assign the Storage File Data Privileged Contributor role + var assignRoleProcess = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"role assignment create --role \"Storage File Data Privileged Contributor\" --assignee {userObjectId} --scope {scope}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (assignRoleProcess == null) + { + await assignRoleTask.CompleteAsync( + "Failed to start az CLI process for role assignment", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + await assignRoleProcess.WaitForExitAsync(context.CancellationToken).ConfigureAwait(false); + + if (assignRoleProcess.ExitCode != 0) + { + var error = await assignRoleProcess.StandardError.ReadToEndAsync(context.CancellationToken).ConfigureAwait(false); + await assignRoleTask.CompleteAsync( + $"Failed to assign role: {error}", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + return; + } + + await assignRoleTask.CompleteAsync( + $"Successfully assigned Storage File Data Privileged Contributor role", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + await assignRoleTask.CompleteAsync( + $"Error assigning role: {ex.Message}", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + } + } + + await roleAssignmentStep.CompleteAsync( + "Role assignment completed", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } +}, requiredBy: "upload-bind-mounts", dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure); + +builder.Pipeline.AddStep("upload-bind-mounts", async (context) => +{ + var resourcesWithBindMounts = context.Model.Resources + .Where(r => r.TryGetContainerMounts(out var mounts) && + mounts.Any(m => m.Type == ContainerMountType.BindMount)) + .ToList(); + + if (resourcesWithBindMounts.Count == 0) + { + return; + } + + var uploadStep = await context.ActivityReporter + .CreateStepAsync($"upload-bind-mounts", context.CancellationToken) + .ConfigureAwait(false); + + await using (uploadStep.ConfigureAwait(false)) + { + var totalUploads = 0; + + var targetEnv = acaEnv.Resource; + var storageAccountName = targetEnv.Outputs["storagE_VOLUME_ACCOUNT_NAME"]?.ToString(); + var resource = withBindMount.Resource; + + if (!resource.TryGetContainerMounts(out var mounts)) + { + return; + } + + var bindMounts = mounts.Where(m => m.Type == ContainerMountType.BindMount).ToList(); + + for (var i = 0; i < bindMounts.Count; i++) + { + var bindMount = bindMounts[i]; + var sourcePath = bindMount.Source; + + if (string.IsNullOrEmpty(sourcePath)) + { + continue; + } + + var fileShareName = targetEnv.Outputs[$"shareS_{i}_NAME"]?.ToString(); + + var uploadTask = await uploadStep + .CreateTaskAsync($"Uploading {Path.GetFileName(sourcePath)} to {fileShareName}", context.CancellationToken) + .ConfigureAwait(false); + + await using (uploadTask.ConfigureAwait(false)) + { + if (!Directory.Exists(sourcePath)) + { + await uploadTask.CompleteAsync( + $"Source path {sourcePath} does not exist", + CompletionState.CompletedWithWarning, + context.CancellationToken).ConfigureAwait(false); + continue; + } + + var files = Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories); + var fileCount = files.Length; + + var credential = new AzureCliCredential(); + var fileShareUri = new Uri($"https://{storageAccountName}.file.core.windows.net/{fileShareName}"); + + var clientOptions = new ShareClientOptions + { + ShareTokenIntent = ShareTokenIntent.Backup + }; + + var shareClient = new ShareClient(fileShareUri, credential, clientOptions); + + foreach (var filePath in files) + { + var relativePath = Path.GetRelativePath(sourcePath, filePath); + var directoryPath = Path.GetDirectoryName(relativePath) ?? string.Empty; + + var directoryClient = shareClient.GetRootDirectoryClient(); + + if (!string.IsNullOrEmpty(directoryPath)) + { + var parts = directoryPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + foreach (var part in parts) + { + directoryClient = directoryClient.GetSubdirectoryClient(part); + await directoryClient.CreateIfNotExistsAsync(cancellationToken: context.CancellationToken).ConfigureAwait(false); + } + } + + var fileName = Path.GetFileName(filePath); + var fileClient = directoryClient.GetFileClient(fileName); + + using var fileStream = File.OpenRead(filePath); + await fileClient.CreateAsync(fileStream.Length, cancellationToken: context.CancellationToken).ConfigureAwait(false); + await fileClient.UploadAsync(fileStream, cancellationToken: context.CancellationToken).ConfigureAwait(false); + } + + await uploadTask.CompleteAsync( + $"Successfully uploaded {fileCount} file(s) from {sourcePath}", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + + totalUploads += fileCount; + } + } + + await uploadStep.CompleteAsync( + $"Successfully uploaded {totalUploads} file(s) to Azure File Shares", + CompletionState.Completed, + context.CancellationToken).ConfigureAwait(false); + } +}, requiredBy: WellKnownPipelineSteps.DeployCompute, dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/pipelines/Pipelines.AppHost/Dockerfile.bindmount b/playground/pipelines/Pipelines.AppHost/Dockerfile.bindmount new file mode 100644 index 00000000000..d7799d05bc0 --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/Dockerfile.bindmount @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/runtime:9.0 + +# Create a directory for the bind mount +RUN mkdir -p /data + +# Set the working directory +WORKDIR /app + +# Create a simple script that reads and returns the file size +RUN printf '#!/bin/sh\n\ +if [ -f /data/sample.txt ]; then\n\ + SIZE=$(stat -f%%z /data/sample.txt 2>/dev/null || stat -c%%s /data/sample.txt 2>/dev/null)\n\ + echo "File size of sample.txt: $SIZE bytes"\n\ +else\n\ + echo "sample.txt not found at /data/sample.txt"\n\ + exit 1\n\ +fi\n' > /app/check-file-size.sh && chmod +x /app/check-file-size.sh + +# Run the script when container starts +ENTRYPOINT ["/app/check-file-size.sh"] diff --git a/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj b/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj new file mode 100644 index 00000000000..c3596368a0f --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj @@ -0,0 +1,24 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + f6688234-abcb-49bc-91cc-04a541ddee18 + + + + + + + + + + + + + + + diff --git a/playground/pipelines/Pipelines.AppHost/Properties/launchSettings.json b/playground/pipelines/Pipelines.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..a29d8902e83 --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/Properties/launchSettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17266;http://localhost:15173", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22065" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15173", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19201", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20169" + } + }, + "deploy": { + "commandName": "Project", + "commandLineArgs": "--publisher default --deploy true --output-path ./deploy-output", + } + } +} diff --git a/playground/pipelines/Pipelines.AppHost/appsettings.Development.json b/playground/pipelines/Pipelines.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/pipelines/Pipelines.AppHost/appsettings.json b/playground/pipelines/Pipelines.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/playground/pipelines/Pipelines.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs new file mode 100644 index 00000000000..db5b0dad913 --- /dev/null +++ b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs @@ -0,0 +1,11 @@ +using Aspire.Hosting.Pipelines; + +namespace Pipelines.Library; + +public static class DistributedApplicationPipelineExtensions +{ + public static IDistributedApplicationPipeline AddAppServiceZipDeploy(this IDistributedApplicationPipeline pipeline) + { + return pipeline; + } +} diff --git a/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj b/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj new file mode 100644 index 00000000000..e634d95e019 --- /dev/null +++ b/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/playground/pipelines/data/sample.txt b/playground/pipelines/data/sample.txt new file mode 100644 index 00000000000..f876cf1684c --- /dev/null +++ b/playground/pipelines/data/sample.txt @@ -0,0 +1,51 @@ +# Sample Data for Pipeline Processing + +## User Records +ID,Name,Email,Department,Salary,JoinDate +1,John Smith,john.smith@example.com,Engineering,75000,2023-01-15 +2,Sarah Johnson,sarah.johnson@example.com,Marketing,65000,2023-02-20 +3,Mike Davis,mike.davis@example.com,Engineering,80000,2022-11-10 +4,Lisa Wilson,lisa.wilson@example.com,Sales,70000,2023-03-05 +5,David Brown,david.brown@example.com,HR,60000,2023-01-30 + +## Application Logs +2024-01-15T10:30:15.123Z INFO Starting application service +2024-01-15T10:30:16.456Z INFO Database connection established +2024-01-15T10:30:17.789Z WARN Slow query detected: SELECT * FROM users WHERE department = 'Engineering' +2024-01-15T10:30:18.012Z INFO Processing 150 user records +2024-01-15T10:30:19.345Z ERROR Failed to process record ID: 42 - Invalid email format +2024-01-15T10:30:20.678Z INFO Successfully processed 149 records +2024-01-15T10:30:21.901Z INFO Application service stopped gracefully + +## Configuration Settings +{ + "database": { + "connectionString": "Server=localhost;Database=AspireDemo;Trusted_Connection=true;", + "timeout": 30 + }, + "messaging": { + "rabbitMQ": { + "host": "localhost", + "port": 5672, + "virtualHost": "/" + } + }, + "redis": { + "connectionString": "localhost:6379" + } +} + +## Sample Messages +MSG001: User registration completed for user ID 1001 +MSG002: Password reset requested for user john.doe@example.com +MSG003: Order #12345 shipped to customer address +MSG004: Payment processed successfully for amount $299.99 +MSG005: Inventory updated: Product SKU-ABC123 quantity changed from 100 to 85 + +## Performance Metrics +Timestamp,CPU_Usage,Memory_MB,Requests_Per_Second,Response_Time_MS +2024-01-15T10:00:00Z,25.4,512,150,45 +2024-01-15T10:01:00Z,32.1,548,175,52 +2024-01-15T10:02:00Z,28.7,534,165,48 +2024-01-15T10:03:00Z,45.2,612,220,67 +2024-01-15T10:04:00Z,38.9,589,198,58 diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 7035ed136f6..5bab4e7a84d 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -86,6 +86,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Action = ctx => PushContainerImagesAsync(context.Model, ctx, context) }; pushStep.DependsOnStep(buildStep); + pushStep.DependsOnStep(provisionStep); var deployStep = new PipelineStep { diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 8d58221fb4d..cff32070dcd 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -19,7 +19,8 @@ public DistributedApplicationPipeline(IDistributedApplicationBuilder builder) public void AddStep(string name, Func action, - string? dependsOn = null) + string? dependsOn = null, + string? requiredBy = null) { var step = new PipelineStep { @@ -32,6 +33,11 @@ public void AddStep(string name, step.DependsOnStep(dependsOn); } + if (requiredBy != null) + { + step.IsRequiredBy(requiredBy); + } + _steps.Add(step); } @@ -71,9 +77,11 @@ private static IEnumerable CollectAnnotatedSteps(DeployingContext foreach (var annotation in annotations) { - yield return annotation.CreateStep(context); + return annotation.CreateSteps(context); } } + + return []; } private static void ValidateSteps(IEnumerable steps) diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index bbb7ee3487c..e262f631584 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -18,9 +18,11 @@ public interface IDistributedApplicationPipeline /// The unique name of the step. /// The action to execute for this step. /// The name of the step this step depends on, if any. + /// The name of the step that requires this step, if any. void AddStep(string name, Func action, - string? dependsOn = null); + string? dependsOn = null, + string? requiredBy = null); /// /// Adds a deployment step to the pipeline. diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index 7721493f508..bba5fb1b192 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -27,6 +27,11 @@ public class PipelineStep /// public List Dependencies { get; } = []; + /// + /// Gets the list of step names that require this step to complete before they can finish. + /// + public List RequiredBy { get; } = []; + /// /// Adds a dependency on another step. /// @@ -44,4 +49,22 @@ public void DependsOnStep(PipelineStep step) { Dependencies.Add(step.Name); } + + /// + /// Specifies that this step is required by another step. + /// + /// The name of the step that requires this step. + public void IsRequiredBy(string stepName) + { + RequiredBy.Add(stepName); + } + + /// + /// Specifies that this step is required by another step. + /// + /// The step that requires this step. + public void IsRequiredBy(PipelineStep step) + { + RequiredBy.Add(step.Name); + } } From b011d6977de69160000f6ab9283f38c28c1497a6 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 15:35:06 -0700 Subject: [PATCH 04/21] Fix handling for required by dependencies --- .../DistributedApplicationPipeline.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index cff32070dcd..f59b89579e4 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -77,11 +77,12 @@ private static IEnumerable CollectAnnotatedSteps(DeployingContext foreach (var annotation in annotations) { - return annotation.CreateSteps(context); + foreach (var step in annotation.CreateSteps(context)) + { + yield return step; + } } } - - return []; } private static void ValidateSteps(IEnumerable steps) @@ -123,6 +124,24 @@ private static List> ResolveDependencies( inDegree[step.Name] = 0; } + foreach (var step in steps) + { + foreach (var requiredByStep in step.RequiredBy) + { + if (!graph.ContainsKey(requiredByStep)) + { + throw new InvalidOperationException( + $"Step '{step.Name}' is required by unknown step '{requiredByStep}'"); + } + + var requiredByStepObj = registry.GetStep(requiredByStep); + if (requiredByStepObj != null && !requiredByStepObj.Dependencies.Contains(step.Name)) + { + requiredByStepObj.Dependencies.Add(step.Name); + } + } + } + foreach (var step in steps) { foreach (var dependency in step.Dependencies) From 1ea4ae307a68f60900b799f6a22f8e90eb18d7c0 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 15:59:12 -0700 Subject: [PATCH 05/21] Add test coverage for DistributedApplicationPipeline --- .../DistributedApplicationBuilder.cs | 4 +- .../DistributedApplicationPipeline.cs | 8 +- .../DistributedApplicationPipelineTests.cs | 473 ++++++++++++++++++ 3 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index b3e403a1b9d..fd5a140e2a6 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -59,7 +59,6 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder private readonly DistributedApplicationOptions _options; private readonly HostApplicationBuilder _innerBuilder; - private IDistributedApplicationPipeline? _pipeline; /// public IHostEnvironment Environment => _innerBuilder.Environment; @@ -89,8 +88,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder public IDistributedApplicationEventing Eventing { get; } = new DistributedApplicationEventing(); /// - public IDistributedApplicationPipeline Pipeline => - _pipeline ??= new DistributedApplicationPipeline(this); + public IDistributedApplicationPipeline Pipeline { get; } = new DistributedApplicationPipeline(); /// /// Initializes a new instance of the class with the specified options. diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index f59b89579e4..5515cc9b5b1 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -9,13 +9,7 @@ namespace Aspire.Hosting.Pipelines; internal sealed class DistributedApplicationPipeline : IDistributedApplicationPipeline { - private readonly List _steps = new(); - private readonly IDistributedApplicationBuilder _builder; - - public DistributedApplicationPipeline(IDistributedApplicationBuilder builder) - { - _builder = builder; - } + private readonly List _steps = []; public void AddStep(string name, Func action, diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs new file mode 100644 index 00000000000..2ff8c4beeaf --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -0,0 +1,473 @@ +// 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 +#pragma warning disable IDE0005 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Pipelines; + +public class DistributedApplicationPipelineTests +{ + [Fact] + public async Task ExecuteAsync_WithNoSteps_CompletesSuccessfully() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + var context = CreateDeployingContext(builder.Build()); + + await pipeline.ExecuteAsync(context); + } + + [Fact] + public async Task ExecuteAsync_WithSingleStep_ExecutesStep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var stepExecuted = false; + pipeline.AddStep("step1", async (context) => + { + stepExecuted = true; + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.True(stepExecuted); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleIndependentSteps_ExecutesAllSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(3, executedSteps.Count); + Assert.Contains("step1", executedSteps); + Assert.Contains("step2", executedSteps); + Assert.Contains("step3", executedSteps); + } + + [Fact] + public async Task ExecuteAsync_WithDependsOn_ExecutesInOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }, dependsOn: "step1"); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }, dependsOn: "step2"); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(["step1", "step2", "step3"], executedSteps); + } + + [Fact] + public async Task ExecuteAsync_WithRequiredBy_ExecutesInCorrectOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }, requiredBy: "step2"); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }, requiredBy: "step3"); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(["step1", "step2", "step3"], executedSteps); + } + + [Fact] + public async Task ExecuteAsync_WithMixedDependsOnAndRequiredBy_ExecutesInCorrectOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }, requiredBy: "step3"); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }, dependsOn: "step1"); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(3, executedSteps.Count); + var step1Index = executedSteps.IndexOf("step1"); + var step2Index = executedSteps.IndexOf("step2"); + var step3Index = executedSteps.IndexOf("step3"); + + Assert.True(step1Index < step3Index, "step1 should execute before step3"); + Assert.True(step2Index < step3Index, "step2 should execute before step3"); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleLevels_ExecutesLevelsInOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executionOrder = new List<(string step, DateTime time)>(); + var level1Complete = new TaskCompletionSource(); + var level2Complete = new TaskCompletionSource(); + + pipeline.AddStep("level1-step1", async (context) => + { + executionOrder.Add(("level1-step1", DateTime.UtcNow)); + await Task.Delay(10); + await Task.CompletedTask; + }); + + pipeline.AddStep("level1-step2", async (context) => + { + executionOrder.Add(("level1-step2", DateTime.UtcNow)); + await Task.Delay(10); + await Task.CompletedTask; + }); + + pipeline.AddStep("level2-step1", async (context) => + { + executionOrder.Add(("level2-step1", DateTime.UtcNow)); + await Task.CompletedTask; + }, dependsOn: "level1-step1"); + + pipeline.AddStep("level2-step2", async (context) => + { + executionOrder.Add(("level2-step2", DateTime.UtcNow)); + await Task.CompletedTask; + }, dependsOn: "level1-step2"); + + pipeline.AddStep("level3-step1", async (context) => + { + executionOrder.Add(("level3-step1", DateTime.UtcNow)); + await Task.CompletedTask; + }, dependsOn: "level2-step1"); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(5, executionOrder.Count); + + var level1Steps = executionOrder.Where(x => x.step.StartsWith("level1-")).ToList(); + var level2Steps = executionOrder.Where(x => x.step.StartsWith("level2-")).ToList(); + var level3Steps = executionOrder.Where(x => x.step.StartsWith("level3-")).ToList(); + + Assert.True(level1Steps.All(l1 => level2Steps.All(l2 => l1.time <= l2.time)), + "All level 1 steps should start before or at same time as level 2 steps"); + Assert.True(level2Steps.All(l2 => level3Steps.All(l3 => l2.time <= l3.time)), + "All level 2 steps should start before or at same time as level 3 steps"); + } + + [Fact] + public async Task ExecuteAsync_WithPipelineStepAnnotation_ExecutesAnnotatedSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var executedSteps = new List(); + var resource = builder.AddResource(new CustomResource("test-resource")) + .WithAnnotation(new PipelineStepAnnotation(context => new PipelineStep + { + Name = "annotated-step", + Action = async (ctx) => + { + executedSteps.Add("annotated-step"); + await Task.CompletedTask; + } + })); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("regular-step", async (context) => + { + executedSteps.Add("regular-step"); + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(2, executedSteps.Count); + Assert.Contains("annotated-step", executedSteps); + Assert.Contains("regular-step", executedSteps); + } + + [Fact] + public async Task ExecuteAsync_WithMultiplePipelineStepAnnotations_ExecutesAllAnnotatedSteps() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var executedSteps = new List(); + var resource = builder.AddResource(new CustomResource("test-resource")) + .WithAnnotation(new PipelineStepAnnotation(context => new[] + { + new PipelineStep + { + Name = "annotated-step-1", + Action = async (ctx) => + { + executedSteps.Add("annotated-step-1"); + await Task.CompletedTask; + } + }, + new PipelineStep + { + Name = "annotated-step-2", + Action = async (ctx) => + { + executedSteps.Add("annotated-step-2"); + await Task.CompletedTask; + } + } + })); + + var pipeline = new DistributedApplicationPipeline(); + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(2, executedSteps.Count); + Assert.Contains("annotated-step-1", executedSteps); + Assert.Contains("annotated-step-2", executedSteps); + } + + [Fact] + public async Task ExecuteAsync_WithDuplicateStepNames_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => await Task.CompletedTask); + pipeline.AddStep("step1", async (context) => await Task.CompletedTask); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Duplicate step name", ex.Message); + Assert.Contains("step1", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WithUnknownDependency_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => await Task.CompletedTask, dependsOn: "unknown-step"); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("depends on unknown step", ex.Message); + Assert.Contains("unknown-step", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WithUnknownRequiredBy_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => await Task.CompletedTask, requiredBy: "unknown-step"); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("required by unknown step", ex.Message); + Assert.Contains("unknown-step", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WithCircularDependency_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var step1 = new PipelineStep + { + Name = "step1", + Action = async (context) => await Task.CompletedTask + }; + step1.DependsOnStep("step2"); + + var step2 = new PipelineStep + { + Name = "step2", + Action = async (context) => await Task.CompletedTask + }; + step2.DependsOnStep("step1"); + + pipeline.AddStep(step1); + pipeline.AddStep(step2); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Circular dependency", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_WhenStepThrows_WrapsExceptionWithStepName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var exceptionMessage = "Test exception"; + pipeline.AddStep("failing-step", async (context) => + { + await Task.CompletedTask; + throw new NotSupportedException(exceptionMessage); + }); + + var context = CreateDeployingContext(builder.Build()); + + var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("failing-step", ex.Message); + Assert.Contains("failed", ex.Message); + Assert.NotNull(ex.InnerException); + Assert.Equal(exceptionMessage, ex.InnerException.Message); + } + + [Fact] + public async Task ExecuteAsync_WithComplexDependencyGraph_ExecutesInCorrectOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep("a", async (context) => + { + executedSteps.Add("a"); + await Task.CompletedTask; + }); + + pipeline.AddStep("b", async (context) => + { + executedSteps.Add("b"); + await Task.CompletedTask; + }, dependsOn: "a"); + + pipeline.AddStep("c", async (context) => + { + executedSteps.Add("c"); + await Task.CompletedTask; + }, dependsOn: "a"); + + pipeline.AddStep("d", async (context) => + { + executedSteps.Add("d"); + await Task.CompletedTask; + }, dependsOn: "b", requiredBy: "e"); + + pipeline.AddStep("e", async (context) => + { + executedSteps.Add("e"); + await Task.CompletedTask; + }, dependsOn: "c"); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + Assert.Equal(5, executedSteps.Count); + + var aIndex = executedSteps.IndexOf("a"); + var bIndex = executedSteps.IndexOf("b"); + var cIndex = executedSteps.IndexOf("c"); + var dIndex = executedSteps.IndexOf("d"); + var eIndex = executedSteps.IndexOf("e"); + + Assert.True(aIndex < bIndex, "a should execute before b"); + Assert.True(aIndex < cIndex, "a should execute before c"); + Assert.True(bIndex < dIndex, "b should execute before d"); + Assert.True(cIndex < eIndex, "c should execute before e"); + Assert.True(dIndex < eIndex, "d should execute before e (requiredBy relationship)"); + } + + private static DeployingContext CreateDeployingContext(DistributedApplication app) + { + return new DeployingContext( + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services, + NullLogger.Instance, + CancellationToken.None, + outputPath: null); + } + + private sealed class CustomResource(string name) : Resource(name) + { + } +} From 706dd6eb82dd465b06be82e3ed62124a82fd552d Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 20:21:20 -0700 Subject: [PATCH 06/21] Mode zip deploy to App Service as pipeline step --- .../pipelines/Pipelines.AppHost/AppHost.cs | 11 + .../Pipelines.AppHost.csproj | 6 + ...istributedApplicationPipelineExtensions.cs | 196 +++++++++++++++++- .../Pipelines.Library.csproj | 3 +- .../AzureAppServiceWebsiteContext.cs | 7 +- .../AzureEnvironmentResource.cs | 2 +- 6 files changed, 221 insertions(+), 4 deletions(-) diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs index 52fecbeca08..5d7585a5bd6 100644 --- a/playground/pipelines/Pipelines.AppHost/AppHost.cs +++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs @@ -1,4 +1,5 @@ #pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIRECOMPUTE001 using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; @@ -7,9 +8,14 @@ using Azure.Provisioning.Storage; using Azure.Storage.Files.Shares; using Azure.Storage.Files.Shares.Models; +using Pipelines.Library; var builder = DistributedApplication.CreateBuilder(args); +builder.Pipeline.AddAppServiceZipDeploy(); + +var aasEnv = builder.AddAzureAppServiceEnvironment("appservice-env"); + var acaEnv = builder.AddAzureContainerAppEnvironment("aca-env") .ConfigureInfrastructure(infra => { @@ -34,6 +40,7 @@ }); var withBindMount = builder.AddDockerfile("withBindMount", ".", "./Dockerfile.bindmount") + .WithComputeEnvironment(acaEnv) .WithBindMount("../data", "/data"); // This step could also be modeled as a Bicep resource with the role assignment @@ -345,6 +352,10 @@ await uploadStep.CompleteAsync( } }, requiredBy: WellKnownPipelineSteps.DeployCompute, dependsOn: WellKnownPipelineSteps.ProvisionInfrastructure); +builder.AddProject("api-service") + .WithComputeEnvironment(aasEnv) + .WithExternalHttpEndpoints(); + #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging // of the dashboard. It is not required in end developer code. Comment out this code diff --git a/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj b/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj index c3596368a0f..3714c48ce9c 100644 --- a/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj +++ b/playground/pipelines/Pipelines.AppHost/Pipelines.AppHost.csproj @@ -17,8 +17,14 @@ + + + + + + diff --git a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs index db5b0dad913..6e905d71afc 100644 --- a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs +++ b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs @@ -1,4 +1,17 @@ -using Aspire.Hosting.Pipelines; +#pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIRECOMPUTE001 +#pragma warning disable ASPIREAZURE001 + +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Azure.Core; +using Azure.Identity; +using Azure.Provisioning; namespace Pipelines.Library; @@ -6,6 +19,187 @@ public static class DistributedApplicationPipelineExtensions { public static IDistributedApplicationPipeline AddAppServiceZipDeploy(this IDistributedApplicationPipeline pipeline) { + pipeline.AddStep("app-service-zip-deploy", async context => + { + var appServiceEnvironments = context.Model.Resources.OfType(); + if (!appServiceEnvironments.Any()) + { + return; + } + + foreach (var appServiceEnvironment in appServiceEnvironments) + { + foreach (var resource in context.Model.GetComputeResources()) + { + var annotation = resource.GetDeploymentTargetAnnotation(); + if (annotation != null && + annotation.ComputeEnvironment == appServiceEnvironment && + annotation.DeploymentTarget is AzureAppServiceWebSiteResource websiteResource) + { + if (resource is not ProjectResource projectResource) + { + continue; + } + + await DeployProjectToAppServiceAsync( + context, + projectResource, + websiteResource, + appServiceEnvironment, + context.CancellationToken).ConfigureAwait(false); + } + } + } + }, dependsOn: WellKnownPipelineSteps.DeployCompute); + return pipeline; } + + private static async Task DeployProjectToAppServiceAsync( + DeployingContext context, + ProjectResource projectResource, + AzureAppServiceWebSiteResource websiteResource, + AzureAppServiceEnvironmentResource appServiceEnvironment, + CancellationToken cancellationToken) + { + var stepName = $"deploy-{projectResource.Name}"; + var step = await context.ActivityReporter.CreateStepAsync(stepName, cancellationToken).ConfigureAwait(false); + + await using (step.ConfigureAwait(false)) + { + var projectMetadata = projectResource.GetProjectMetadata(); + var projectPath = projectMetadata.ProjectPath; + + var publishTask = await step.CreateTaskAsync($"Publishing {projectResource.Name}", cancellationToken).ConfigureAwait(false); + await using (publishTask.ConfigureAwait(false)) + { + var publishDir = Path.Combine(Path.GetTempPath(), $"aspire-publish-{Guid.NewGuid()}"); + Directory.CreateDirectory(publishDir); + + try + { + var publishProcess = Process.Start(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"publish \"{projectPath}\" -c Release -o \"{publishDir}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + if (publishProcess == null) + { + await publishTask.CompleteAsync( + "Failed to start dotnet publish", + CompletionState.CompletedWithError, + cancellationToken).ConfigureAwait(false); + return; + } + + await publishProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (publishProcess.ExitCode != 0) + { + var error = await publishProcess.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await publishTask.CompleteAsync( + $"Publish failed: {error}", + CompletionState.CompletedWithError, + cancellationToken).ConfigureAwait(false); + return; + } + + await publishTask.CompleteAsync( + "Publish completed", + CompletionState.Completed, + cancellationToken).ConfigureAwait(false); + + var zipTask = await step.CreateTaskAsync($"Creating deployment package", cancellationToken).ConfigureAwait(false); + await using (zipTask.ConfigureAwait(false)) + { + var zipPath = Path.Combine(Path.GetTempPath(), $"aspire-deploy-{Guid.NewGuid()}.zip"); + + ZipFile.CreateFromDirectory(publishDir, zipPath); + + await zipTask.CompleteAsync( + "Deployment package created", + CompletionState.Completed, + cancellationToken).ConfigureAwait(false); + + var uploadTask = await step.CreateTaskAsync($"Uploading to {projectResource.Name}", cancellationToken).ConfigureAwait(false); + await using (uploadTask.ConfigureAwait(false)) + { + try + { + var siteName = websiteResource.Outputs[$"{Infrastructure.NormalizeBicepIdentifier(websiteResource.Name)}_name"]?.ToString(); + if (string.IsNullOrEmpty(siteName)) + { + siteName = appServiceEnvironment.Outputs["name"]?.ToString(); + } + + if (string.IsNullOrEmpty(siteName)) + { + await uploadTask.CompleteAsync( + "Could not determine website name", + CompletionState.CompletedWithError, + cancellationToken).ConfigureAwait(false); + return; + } + + var credential = new AzureCliCredential(); + var tokenRequestContext = new TokenRequestContext(["https://management.azure.com/.default"]); + var accessToken = await credential.GetTokenAsync(tokenRequestContext, cancellationToken).ConfigureAwait(false); + + var kuduUrl = $"https://{siteName}.scm.azurewebsites.net/api/zipdeploy"; + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + httpClient.Timeout = TimeSpan.FromMinutes(30); + + await using var zipStream = File.OpenRead(zipPath); + using var content = new StreamContent(zipStream); + content.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + + var response = await httpClient.PostAsync(kuduUrl, content, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + await uploadTask.CompleteAsync( + $"Upload failed: {response.StatusCode} - {errorContent}", + CompletionState.CompletedWithError, + cancellationToken).ConfigureAwait(false); + return; + } + + await uploadTask.CompleteAsync( + "Upload completed successfully", + CompletionState.Completed, + cancellationToken).ConfigureAwait(false); + } + finally + { + if (File.Exists(zipPath)) + { + File.Delete(zipPath); + } + } + } + } + } + finally + { + if (Directory.Exists(publishDir)) + { + Directory.Delete(publishDir, recursive: true); + } + } + } + + await step.CompleteAsync( + "Deployment completed", + CompletionState.Completed, + cancellationToken).ConfigureAwait(false); + } + } } diff --git a/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj b/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj index e634d95e019..87a340109f1 100644 --- a/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj +++ b/playground/pipelines/Pipelines.Library/Pipelines.Library.csproj @@ -1,13 +1,14 @@  - net10.0 + $(DefaultTargetFramework) enable enable + diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 5dccffdb0e2..93d0d8da57e 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -213,7 +213,7 @@ public void BuildWebSite(AzureResourceInfrastructure infra) var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra); var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra); var containerImage = AllocateParameter(new ContainerImageReference(Resource)); - + var webSite = new WebSite("webapp") { // Use the host name as the name of the web app @@ -240,6 +240,11 @@ public void BuildWebSite(AzureResourceInfrastructure infra) }, }; + infra.Add(new ProvisioningOutput($"{Infrastructure.NormalizeBicepIdentifier(resource.Name)}_name", typeof(string)) + { + Value = webSite.Name + }); + // Defining the main container for the app service var mainContainer = new SiteContainer("mainContainer") { diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 5bab4e7a84d..1f0627aa530 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -96,7 +96,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet deployStep.DependsOnStep(pushStep); deployStep.DependsOnStep(provisionStep); - return new[] { validateStep, provisionStep, buildStep, pushStep, deployStep }; + return [validateStep, provisionStep, buildStep, pushStep, deployStep]; })); Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); From 143a68309a89da0a38adf57e89862bf0e7557d88 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 20:44:22 -0700 Subject: [PATCH 07/21] Add experimental attribute and remove unused APIs --- .../pipelines/Pipelines.AppHost/AppHost.cs | 1 + .../DistributedApplicationPipelineExtensions.cs | 1 + .../AzureEnvironmentResource.cs | 10 +++++----- .../DistributedApplicationTestingBuilder.cs | 3 +++ .../DistributedApplicationBuilder.cs | 1 + .../IDistributedApplicationBuilder.cs | 2 ++ .../Pipelines/DistributedApplicationPipeline.cs | 3 ++- .../Pipelines/IDistributedApplicationPipeline.cs | 2 ++ .../Pipelines/IPipelineRegistry.cs | 2 ++ src/Aspire.Hosting/Pipelines/PipelineStep.cs | 6 ++++-- .../Pipelines/PipelineStepAnnotation.cs | 2 ++ .../Pipelines/WellKnownPipelineSteps.cs | 3 +++ .../Publishing/DeployingContext.cs | 16 ---------------- src/Aspire.Hosting/Publishing/Publisher.cs | 1 + .../DistributedApplicationPipelineTests.cs | 5 +++-- 15 files changed, 32 insertions(+), 26 deletions(-) diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs index 5d7585a5bd6..3453c508e1e 100644 --- a/playground/pipelines/Pipelines.AppHost/AppHost.cs +++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs @@ -1,5 +1,6 @@ #pragma warning disable ASPIREPUBLISHERS001 #pragma warning disable ASPIRECOMPUTE001 +#pragma warning disable ASPIREPIPELINES001 using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; diff --git a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs index 6e905d71afc..8e06ea866bc 100644 --- a/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs +++ b/playground/pipelines/Pipelines.Library/DistributedApplicationPipelineExtensions.cs @@ -1,6 +1,7 @@ #pragma warning disable ASPIREPUBLISHERS001 #pragma warning disable ASPIRECOMPUTE001 #pragma warning disable ASPIREAZURE001 +#pragma warning disable ASPIREPIPELINES001 using System.Diagnostics; using System.IO.Compression; diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 1f0627aa530..a85f60d793f 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -72,7 +72,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Name = WellKnownPipelineSteps.ProvisionInfrastructure, Action = ctx => ProvisionAzureBicepResourcesAsync(context.Model, ctx, context) }; - provisionStep.DependsOnStep(validateStep); + provisionStep.DependsOn(validateStep); var buildStep = new PipelineStep { @@ -85,16 +85,16 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Name = "push-container-images", Action = ctx => PushContainerImagesAsync(context.Model, ctx, context) }; - pushStep.DependsOnStep(buildStep); - pushStep.DependsOnStep(provisionStep); + pushStep.DependsOn(buildStep); + pushStep.DependsOn(provisionStep); var deployStep = new PipelineStep { Name = WellKnownPipelineSteps.DeployCompute, Action = ctx => DeployComputeResourcesAsync(context.Model, ctx, context) }; - deployStep.DependsOnStep(pushStep); - deployStep.DependsOnStep(provisionStep); + deployStep.DependsOn(pushStep); + deployStep.DependsOn(provisionStep); return [validateStep, provisionStep, buildStep, pushStep, deployStep]; })); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index db806cb9759..60c6a7bc4e1 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -1,5 +1,8 @@ // 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 ASPIREPIPELINES001 + using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index fd5a140e2a6..ff58a78147a 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIREPIPELINES001 using System.Diagnostics; using System.Reflection; diff --git a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs index ca89926b81a..2d4b94dc6be 100644 --- a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs @@ -1,6 +1,8 @@ // 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 ASPIREPIPELINES001 + using System.Reflection; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 5515cc9b5b1..33553d8afda 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIREPIPELINES001 using Aspire.Hosting.ApplicationModel; @@ -24,7 +25,7 @@ public void AddStep(string name, if (dependsOn != null) { - step.DependsOnStep(dependsOn); + step.DependsOn(dependsOn); } if (requiredBy != null) diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index e262f631584..328c2c65dcf 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPUBLISHERS001 +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Pipelines; @@ -10,6 +11,7 @@ namespace Aspire.Hosting.Pipelines; /// /// Represents a pipeline for executing deployment steps in a distributed application. /// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public interface IDistributedApplicationPipeline { /// diff --git a/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs b/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs index b4172b69ed1..51b596aa4e0 100644 --- a/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs +++ b/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs @@ -1,6 +1,8 @@ // 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 ASPIREPIPELINES001 + namespace Aspire.Hosting.Pipelines; internal interface IPipelineRegistry diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index bba5fb1b192..19f752a6626 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPUBLISHERS001 +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Pipelines; @@ -10,6 +11,7 @@ namespace Aspire.Hosting.Pipelines; /// /// Represents a step in the deployment pipeline. /// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public class PipelineStep { /// @@ -36,7 +38,7 @@ public class PipelineStep /// Adds a dependency on another step. /// /// The name of the step to depend on. - public void DependsOnStep(string stepName) + public void DependsOn(string stepName) { Dependencies.Add(stepName); } @@ -45,7 +47,7 @@ public void DependsOnStep(string stepName) /// Adds a dependency on another step. /// /// The step to depend on. - public void DependsOnStep(PipelineStep step) + public void DependsOn(PipelineStep step) { Dependencies.Add(step.Name); } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs index 68c3e14f8ae..6cca074d0b8 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPUBLISHERS001 +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Pipelines; @@ -10,6 +11,7 @@ namespace Aspire.Hosting.Pipelines; /// /// An annotation that creates a pipeline step for a resource during deployment. /// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public class PipelineStepAnnotation : IResourceAnnotation { private readonly Func> _factory; diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs index be5ee0b4329..420cccb9613 100644 --- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs +++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs @@ -1,11 +1,14 @@ // 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; + namespace Aspire.Hosting.Pipelines; /// /// Defines well-known pipeline step names used in the deployment process. /// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static class WellKnownPipelineSteps { /// diff --git a/src/Aspire.Hosting/Publishing/DeployingContext.cs b/src/Aspire.Hosting/Publishing/DeployingContext.cs index 46d4b496a3e..e2a512f9443 100644 --- a/src/Aspire.Hosting/Publishing/DeployingContext.cs +++ b/src/Aspire.Hosting/Publishing/DeployingContext.cs @@ -111,20 +111,4 @@ public T GetPipelineOutput(string key) } return value; } - - /// - /// Invokes deploying callbacks for each resource in the provided distributed application model. - /// - /// The distributed application model whose resources will be processed. - /// A task representing the asynchronous operation. - internal async Task WriteModelAsync(DistributedApplicationModel model) - { - foreach (var resource in model.Resources) - { - if (resource.TryGetLastAnnotation(out var annotation)) - { - await annotation.Callback(this).ConfigureAwait(false); - } - } - } } diff --git a/src/Aspire.Hosting/Publishing/Publisher.cs b/src/Aspire.Hosting/Publishing/Publisher.cs index 39592e24b2c..0c9d18523a5 100644 --- a/src/Aspire.Hosting/Publishing/Publisher.cs +++ b/src/Aspire.Hosting/Publishing/Publisher.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPUBLISHERS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREPIPELINES001 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 2ff8c4beeaf..a58d89c772b 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPUBLISHERS001 +#pragma warning disable ASPIREPIPELINES001 #pragma warning disable IDE0005 using Aspire.Hosting.ApplicationModel; @@ -360,14 +361,14 @@ public async Task ExecuteAsync_WithCircularDependency_ThrowsInvalidOperationExce Name = "step1", Action = async (context) => await Task.CompletedTask }; - step1.DependsOnStep("step2"); + step1.DependsOn("step2"); var step2 = new PipelineStep { Name = "step2", Action = async (context) => await Task.CompletedTask }; - step2.DependsOnStep("step1"); + step2.DependsOn("step1"); pipeline.AddStep(step1); pipeline.AddStep(step2); From a177ba4ebf96c4e39b88c4562ac27640b432ae40 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 9 Oct 2025 21:33:40 -0700 Subject: [PATCH 08/21] Support multiple dependencies in add steps API --- .../pipelines/Pipelines.AppHost/AppHost.cs | 2 +- .../DistributedApplicationPipeline.cs | 74 ++++++++-- .../IDistributedApplicationPipeline.cs | 8 +- .../DistributedApplicationPipelineTests.cs | 131 ++++++++++++++++++ 4 files changed, 196 insertions(+), 19 deletions(-) diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs index 3453c508e1e..0cacaed5484 100644 --- a/playground/pipelines/Pipelines.AppHost/AppHost.cs +++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs @@ -40,7 +40,7 @@ } }); -var withBindMount = builder.AddDockerfile("withBindMount", ".", "./Dockerfile.bindmount") +var withBindMount = builder.AddDockerfile("with-bind-mount", ".", "./Dockerfile.bindmount") .WithComputeEnvironment(acaEnv) .WithBindMount("../data", "/data"); diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index 33553d8afda..e04cd2e8d0b 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -13,9 +13,9 @@ internal sealed class DistributedApplicationPipeline : IDistributedApplicationPi private readonly List _steps = []; public void AddStep(string name, - Func action, - string? dependsOn = null, - string? requiredBy = null) + Func action, + object? dependsOn = null, + object? requiredBy = null) { var step = new PipelineStep { @@ -25,17 +25,59 @@ public void AddStep(string name, if (dependsOn != null) { - step.DependsOn(dependsOn); + AddDependencies(step, dependsOn); } if (requiredBy != null) { - step.IsRequiredBy(requiredBy); + AddRequiredBy(step, requiredBy); } _steps.Add(step); } + private static void AddDependencies(PipelineStep step, object dependsOn) + { + if (dependsOn is string stepName) + { + step.DependsOn(stepName); + } + else if (dependsOn is IEnumerable stepNames) + { + foreach (var name in stepNames) + { + step.DependsOn(name); + } + } + else + { + throw new ArgumentException( + $"The dependsOn parameter must be a string or IEnumerable, but was {dependsOn.GetType().Name}.", + nameof(dependsOn)); + } + } + + private static void AddRequiredBy(PipelineStep step, object requiredBy) + { + if (requiredBy is string stepName) + { + step.IsRequiredBy(stepName); + } + else if (requiredBy is IEnumerable stepNames) + { + foreach (var name in stepNames) + { + step.IsRequiredBy(name); + } + } + else + { + throw new ArgumentException( + $"The requiredBy parameter must be a string or IEnumerable, but was {requiredBy.GetType().Name}.", + nameof(requiredBy)); + } + } + public void AddStep(PipelineStep step) { _steps.Add(step); @@ -43,7 +85,7 @@ public void AddStep(PipelineStep step) public async Task ExecuteAsync(DeployingContext context) { - var allSteps = _steps.Concat(CollectAnnotatedSteps(context)).ToList(); + var allSteps = _steps.Concat(CollectStepsFromAnnotations(context)).ToList(); if (allSteps.Count == 0) { @@ -63,7 +105,7 @@ await Task.WhenAll(level.Select(step => } } - private static IEnumerable CollectAnnotatedSteps(DeployingContext context) + private static IEnumerable CollectStepsFromAnnotations(DeployingContext context) { foreach (var resource in context.Model.Resources) { @@ -103,6 +145,15 @@ private static void ValidateSteps(IEnumerable steps) $"Step '{step.Name}' depends on unknown step '{dependency}'"); } } + + foreach (var requiredBy in step.RequiredBy) + { + if (!stepNames.Contains(requiredBy)) + { + throw new InvalidOperationException( + $"Step '{step.Name}' is required by unknown step '{requiredBy}'"); + } + } } } @@ -203,14 +254,9 @@ private static async Task ExecuteStepAsync(PipelineStep step, DeployingContext c } } - private sealed class PipelineRegistry : IPipelineRegistry + private sealed class PipelineRegistry(IEnumerable steps) : IPipelineRegistry { - private readonly Dictionary _stepsByName; - - public PipelineRegistry(IEnumerable steps) - { - _stepsByName = steps.ToDictionary(s => s.Name); - } + private readonly Dictionary _stepsByName = steps.ToDictionary(s => s.Name); public IEnumerable GetAllSteps() => _stepsByName.Values; diff --git a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs index 328c2c65dcf..8ccdc0e9834 100644 --- a/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/IDistributedApplicationPipeline.cs @@ -19,12 +19,12 @@ public interface IDistributedApplicationPipeline /// /// The unique name of the step. /// The action to execute for this step. - /// The name of the step this step depends on, if any. - /// The name of the step that requires this step, if any. + /// The name of the step this step depends on, or a list of step names. + /// The name of the step that requires this step, or a list of step names. void AddStep(string name, Func action, - string? dependsOn = null, - string? requiredBy = null); + object? dependsOn = null, + object? requiredBy = null); /// /// Adds a deployment step to the pipeline. diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index a58d89c772b..eeab5ec0d75 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -457,6 +457,137 @@ public async Task ExecuteAsync_WithComplexDependencyGraph_ExecutesInCorrectOrder Assert.True(dIndex < eIndex, "d should execute before e (requiredBy relationship)"); } + [Fact] + public async Task ExecuteAsync_WithMultipleDependencies_ExecutesInCorrectOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }, dependsOn: new[] { "step1", "step2" }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + var step1Index = executedSteps.IndexOf("step1"); + var step2Index = executedSteps.IndexOf("step2"); + var step3Index = executedSteps.IndexOf("step3"); + + Assert.True(step1Index < step3Index, "step1 should execute before step3"); + Assert.True(step2Index < step3Index, "step2 should execute before step3"); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleRequiredBy_ExecutesInCorrectOrder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + pipeline.AddStep("step1", async (context) => + { + executedSteps.Add("step1"); + await Task.CompletedTask; + }, requiredBy: new[] { "step2", "step3" }); + + pipeline.AddStep("step2", async (context) => + { + executedSteps.Add("step2"); + await Task.CompletedTask; + }); + + pipeline.AddStep("step3", async (context) => + { + executedSteps.Add("step3"); + await Task.CompletedTask; + }); + + var context = CreateDeployingContext(builder.Build()); + await pipeline.ExecuteAsync(context); + + var step1Index = executedSteps.IndexOf("step1"); + var step2Index = executedSteps.IndexOf("step2"); + var step3Index = executedSteps.IndexOf("step3"); + + Assert.True(step1Index < step2Index, "step1 should execute before step2"); + Assert.True(step1Index < step3Index, "step1 should execute before step3"); + } + + [Fact] + public async Task ExecuteAsync_WithUnknownRequiredByStep_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => + { + await Task.CompletedTask; + }, requiredBy: "unknown-step"); + + var context = CreateDeployingContext(builder.Build()); + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Step 'step1' is required by unknown step 'unknown-step'", exception.Message); + } + + [Fact] + public async Task ExecuteAsync_WithUnknownRequiredByStepInList_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => + { + await Task.CompletedTask; + }); + + pipeline.AddStep("step2", async (context) => + { + await Task.CompletedTask; + }, requiredBy: new[] { "step1", "unknown-step" }); + + var context = CreateDeployingContext(builder.Build()); + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Step 'step2' is required by unknown step 'unknown-step'", exception.Message); + } + + [Fact] + public void AddStep_WithInvalidDependsOnType_ThrowsArgumentException() + { + var pipeline = new DistributedApplicationPipeline(); + + var exception = Assert.Throws(() => + pipeline.AddStep("step1", async (context) => await Task.CompletedTask, dependsOn: 123)); + + Assert.Contains("The dependsOn parameter must be a string or IEnumerable", exception.Message); + } + + [Fact] + public void AddStep_WithInvalidRequiredByType_ThrowsArgumentException() + { + var pipeline = new DistributedApplicationPipeline(); + + var exception = Assert.Throws(() => + pipeline.AddStep("step1", async (context) => await Task.CompletedTask, requiredBy: 123)); + + Assert.Contains("The requiredBy parameter must be a string or IEnumerable", exception.Message); + } + private static DeployingContext CreateDeployingContext(DistributedApplication app) { return new DeployingContext( From ca8e980a839e8da8ac1e3c7470af35ea88a44083 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 09:16:46 -0700 Subject: [PATCH 09/21] Remove output and registry types, fix DI, fix dashboard URL --- .../pipelines/Pipelines.AppHost/AppHost.cs | 8 +- .../AzureEnvironmentResource.cs | 118 ++++++++++++------ .../Provisioners/BicepProvisioner.cs | 4 +- .../DistributedApplicationBuilder.cs | 2 +- .../IDistributedApplicationBuilder.cs | 2 + .../DistributedApplicationPipeline.cs | 60 +++++++-- .../Pipelines/IPipelineOutputs.cs | 12 -- .../Pipelines/IPipelineRegistry.cs | 12 -- .../Pipelines/InMemoryPipelineOutputs.cs | 28 ----- .../Pipelines/PipelineStepAnnotation.cs | 13 +- .../Pipelines/WellKnownPipelineSteps.cs | 4 +- .../Publishing/DeployingContext.cs | 20 +-- src/Aspire.Hosting/Publishing/Publisher.cs | 4 +- .../DistributedApplicationPipelineTests.cs | 26 ++-- 14 files changed, 172 insertions(+), 141 deletions(-) delete mode 100644 src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs delete mode 100644 src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs delete mode 100644 src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs diff --git a/playground/pipelines/Pipelines.AppHost/AppHost.cs b/playground/pipelines/Pipelines.AppHost/AppHost.cs index 0cacaed5484..f1fe1670078 100644 --- a/playground/pipelines/Pipelines.AppHost/AppHost.cs +++ b/playground/pipelines/Pipelines.AppHost/AppHost.cs @@ -58,8 +58,7 @@ return; } - var targetEnv = acaEnv.Resource; - var storageAccountName = targetEnv.Outputs["storagE_VOLUME_ACCOUNT_NAME"]?.ToString(); + var storageAccountName = await acaEnv.GetOutput("storagE_VOLUME_ACCOUNT_NAME").GetValueAsync(); if (string.IsNullOrEmpty(storageAccountName)) { @@ -261,8 +260,7 @@ await roleAssignmentStep.CompleteAsync( { var totalUploads = 0; - var targetEnv = acaEnv.Resource; - var storageAccountName = targetEnv.Outputs["storagE_VOLUME_ACCOUNT_NAME"]?.ToString(); + var storageAccountName = await acaEnv.GetOutput("storagE_VOLUME_ACCOUNT_NAME").GetValueAsync(); var resource = withBindMount.Resource; if (!resource.TryGetContainerMounts(out var mounts)) @@ -282,7 +280,7 @@ await roleAssignmentStep.CompleteAsync( continue; } - var fileShareName = targetEnv.Outputs[$"shareS_{i}_NAME"]?.ToString(); + var fileShareName = await acaEnv.GetOutput($"shareS_{i}_NAME").GetValueAsync(); var uploadTask = await uploadStep .CreateTaskAsync($"Uploading {Path.GetFileName(sourcePath)} to {fileShareName}", context.CancellationToken) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index a85f60d793f..364ad9dfdaf 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -59,31 +59,31 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet { Annotations.Add(new PublishingCallbackAnnotation(PublishAsync)); - Annotations.Add(new PipelineStepAnnotation(context => + Annotations.Add(new PipelineStepAnnotation(() => { var validateStep = new PipelineStep { Name = "validate-azure-cli-login", - Action = ctx => ValidateAzureCliLoginAsync(ctx, context) + Action = ctx => ValidateAzureCliLoginAsync(ctx) }; var provisionStep = new PipelineStep { Name = WellKnownPipelineSteps.ProvisionInfrastructure, - Action = ctx => ProvisionAzureBicepResourcesAsync(context.Model, ctx, context) + Action = ctx => ProvisionAzureBicepResourcesAsync(ctx) }; provisionStep.DependsOn(validateStep); var buildStep = new PipelineStep { - Name = WellKnownPipelineSteps.BuildImages, - Action = ctx => BuildContainerImagesAsync(context.Model, ctx, context) + Name = WellKnownPipelineSteps.BuildCompute, + Action = ctx => BuildContainerImagesAsync(ctx) }; var pushStep = new PipelineStep { Name = "push-container-images", - Action = ctx => PushContainerImagesAsync(context.Model, ctx, context) + Action = ctx => PushContainerImagesAsync(ctx) }; pushStep.DependsOn(buildStep); pushStep.DependsOn(provisionStep); @@ -91,12 +91,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var deployStep = new PipelineStep { Name = WellKnownPipelineSteps.DeployCompute, - Action = ctx => DeployComputeResourcesAsync(context.Model, ctx, context) + Action = ctx => DeployComputeResourcesAsync(ctx) }; deployStep.DependsOn(pushStep); deployStep.DependsOn(provisionStep); - return [validateStep, provisionStep, buildStep, pushStep, deployStep]; + var printDashboardUrlStep = new PipelineStep + { + Name = "print-dashboard-url", + Action = ctx => PrintDashboardUrlAsync(ctx) + }; + printDashboardUrlStep.DependsOn(deployStep); + + return [validateStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep]; })); Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); @@ -119,9 +126,9 @@ private Task PublishAsync(PublishingContext context) return publishingContext.WriteModelAsync(context.Model, this); } - private static async Task ValidateAzureCliLoginAsync(DeployingContext context, DeployingContext resourceContext) + private static async Task ValidateAzureCliLoginAsync(DeployingContext context) { - var tokenCredentialProvider = resourceContext.Services.GetRequiredService(); + var tokenCredentialProvider = context.Services.GetRequiredService(); if (tokenCredentialProvider.TokenCredential is not AzureCliCredential azureCliCredential) { @@ -154,15 +161,12 @@ await validationStep.FailAsync( } } - private static async Task ProvisionAzureBicepResourcesAsync( - DistributedApplicationModel model, - DeployingContext context, - DeployingContext resourceContext) + private static async Task ProvisionAzureBicepResourcesAsync(DeployingContext context) { - var provisioningContextProvider = resourceContext.Services.GetRequiredService(); - var deploymentStateManager = resourceContext.Services.GetRequiredService(); - var bicepProvisioner = resourceContext.Services.GetRequiredService(); - var configuration = resourceContext.Services.GetRequiredService(); + var provisioningContextProvider = context.Services.GetRequiredService(); + var deploymentStateManager = context.Services.GetRequiredService(); + var bicepProvisioner = context.Services.GetRequiredService(); + var configuration = context.Services.GetRequiredService(); var userSecrets = await deploymentStateManager.LoadStateAsync(context.CancellationToken) .ConfigureAwait(false); @@ -180,7 +184,7 @@ await deploymentStateManager.SaveStateAsync( context.SetPipelineOutput("ProvisioningContext", provisioningContext); - var bicepResources = model.Resources.OfType() + var bicepResources = context.Model.Resources.OfType() .Where(r => !r.IsExcludedFromPublish()) .Where(r => r.ProvisioningTaskCompletionSource == null || !r.ProvisioningTaskCompletionSource.Task.IsCompleted) @@ -259,14 +263,11 @@ await deploymentStateManager.SaveStateAsync( } } - private static async Task BuildContainerImagesAsync( - DistributedApplicationModel model, - DeployingContext context, - DeployingContext resourceContext) + private static async Task BuildContainerImagesAsync(DeployingContext context) { - var containerImageBuilder = resourceContext.Services.GetRequiredService(); + var containerImageBuilder = context.Services.GetRequiredService(); - var computeResources = model.GetComputeResources() + var computeResources = context.Model.GetComputeResources() .Where(r => r.RequiresImageBuildAndPush()) .ToList(); @@ -295,16 +296,13 @@ await containerImageBuilder.BuildImagesAsync( context.CancellationToken).ConfigureAwait(false); } - private static async Task PushContainerImagesAsync( - DistributedApplicationModel model, - DeployingContext context, - DeployingContext resourceContext) + private static async Task PushContainerImagesAsync(DeployingContext context) { - var containerImageBuilder = resourceContext.Services.GetRequiredService(); - var processRunner = resourceContext.Services.GetRequiredService(); - var configuration = resourceContext.Services.GetRequiredService(); + var containerImageBuilder = context.Services.GetRequiredService(); + var processRunner = context.Services.GetRequiredService(); + var configuration = context.Services.GetRequiredService(); - var computeResources = model.GetComputeResources() + var computeResources = context.Model.GetComputeResources() .Where(r => r.RequiresImageBuildAndPush()) .ToList(); @@ -334,15 +332,11 @@ await PushImagesToAllRegistriesAsync(resourcesByRegistry, context, containerImag .ConfigureAwait(false); } - private static async Task DeployComputeResourcesAsync( - DistributedApplicationModel model, - DeployingContext context, - DeployingContext resourceContext) + private static async Task DeployComputeResourcesAsync(DeployingContext context) { - var bicepProvisioner = resourceContext.Services.GetRequiredService(); - var provisioningContext = context.GetPipelineOutput("ProvisioningContext"); - var computeResources = model.GetComputeResources().ToList(); + var bicepProvisioner = context.Services.GetRequiredService(); + var computeResources = context.Model.GetComputeResources().ToList(); if (computeResources.Count == 0) { @@ -679,4 +673,48 @@ private static string ExtractDeepestErrorMessage(JsonArray detailsArray) return string.Empty; } + + private static async Task PrintDashboardUrlAsync(DeployingContext context) + { + var dashboardUrl = TryGetDashboardUrl(context.Model); + + if (dashboardUrl != null) + { + var urlStep = await context.ActivityReporter + .CreateStepAsync("Dashboard URL available", context.CancellationToken) + .ConfigureAwait(false); + + await using (urlStep.ConfigureAwait(false)) + { + await urlStep.SucceedAsync( + $"Dashboard available at: {dashboardUrl}", + context.CancellationToken).ConfigureAwait(false); + } + } + } + + private static string? TryGetDashboardUrl(DistributedApplicationModel model) + { + foreach (var resource in model.Resources) + { + if (resource is IAzureComputeEnvironmentResource && + resource is AzureBicepResource environmentBicepResource) + { + // If the resource is a compute environment, we can use its properties + // to construct the dashboard URL. + if (environmentBicepResource.Outputs.TryGetValue($"AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", out var domainValue)) + { + return $"https://aspire-dashboard.ext.{domainValue}"; + } + // If the resource is a compute environment (app service), we can use its properties + // to get the dashboard URL. + if (environmentBicepResource.Outputs.TryGetValue($"AZURE_APP_SERVICE_DASHBOARD_URI", out var dashboardUri)) + { + return (string?)dashboardUri; + } + } + } + + return null; + } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index 0049fa79326..c64640a6dd7 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -60,7 +60,7 @@ public async Task ConfigureResourceAsync(IConfiguration configuration, Azu { // TODO: Handle complex output types // Populate the resource outputs - resource.Outputs[item.Key] = item.Value?.Prop("value").ToString(); + resource.Outputs[item.Key] = item.Value?.Prop("value")?.ToString(); } } @@ -257,7 +257,7 @@ await notificationService.PublishUpdateAsync(resource, state => { // TODO: Handle complex output types // Populate the resource outputs - resource.Outputs[item.Key] = item.Value?.Prop("value").ToString(); + resource.Outputs[item.Key] = item.Value?.Prop("value")?.ToString(); } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index ff58a78147a..4da5c90f222 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -418,7 +418,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(Pipeline); // Register IDeploymentStateManager based on execution context if (ExecutionContext.IsPublishMode) diff --git a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs index 2d4b94dc6be..c3da140e310 100644 --- a/src/Aspire.Hosting/IDistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/IDistributedApplicationBuilder.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; @@ -132,6 +133,7 @@ public interface IDistributedApplicationBuilder /// The pipeline allows adding custom deployment steps that execute during the deploy process. /// Steps can declare dependencies on other steps to control execution order. /// + [Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public IDistributedApplicationPipeline Pipeline { get; } /// diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index e04cd2e8d0b..d676807b590 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -4,10 +4,14 @@ #pragma warning disable ASPIREPUBLISHERS001 #pragma warning disable ASPIREPIPELINES001 +using System.Diagnostics; +using System.Globalization; +using System.Text; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Pipelines; +[DebuggerDisplay("{ToString(),nq}")] internal sealed class DistributedApplicationPipeline : IDistributedApplicationPipeline { private readonly List _steps = []; @@ -17,6 +21,12 @@ public void AddStep(string name, object? dependsOn = null, object? requiredBy = null) { + if (_steps.Any(s => s.Name == name)) + { + throw new InvalidOperationException( + $"A step with the name '{name}' has already been added to the pipeline."); + } + var step = new PipelineStep { Name = name, @@ -80,6 +90,12 @@ private static void AddRequiredBy(PipelineStep step, object requiredBy) public void AddStep(PipelineStep step) { + if (_steps.Any(s => s.Name == step.Name)) + { + throw new InvalidOperationException( + $"A step with the name '{step.Name}' has already been added to the pipeline."); + } + _steps.Add(step); } @@ -94,9 +110,9 @@ public async Task ExecuteAsync(DeployingContext context) ValidateSteps(allSteps); - var registry = new PipelineRegistry(allSteps); + var stepsByName = allSteps.ToDictionary(s => s.Name); - var levels = ResolveDependencies(allSteps, registry); + var levels = ResolveDependencies(allSteps, stepsByName); foreach (var level in levels) { @@ -114,7 +130,7 @@ private static IEnumerable CollectStepsFromAnnotations(DeployingCo foreach (var annotation in annotations) { - foreach (var step in annotation.CreateSteps(context)) + foreach (var step in annotation.CreateSteps()) { yield return step; } @@ -159,7 +175,7 @@ private static void ValidateSteps(IEnumerable steps) private static List> ResolveDependencies( IEnumerable steps, - IPipelineRegistry registry) + Dictionary stepsByName) { var graph = new Dictionary>(); var inDegree = new Dictionary(); @@ -180,8 +196,8 @@ private static List> ResolveDependencies( $"Step '{step.Name}' is required by unknown step '{requiredByStep}'"); } - var requiredByStepObj = registry.GetStep(requiredByStep); - if (requiredByStepObj != null && !requiredByStepObj.Dependencies.Contains(step.Name)) + if (stepsByName.TryGetValue(requiredByStep, out var requiredByStepObj) && + !requiredByStepObj.Dependencies.Contains(step.Name)) { requiredByStepObj.Dependencies.Add(step.Name); } @@ -216,7 +232,7 @@ private static List> ResolveDependencies( for (var i = 0; i < levelSize; i++) { var stepName = queue.Dequeue(); - var step = registry.GetStep(stepName)!; + var step = stepsByName[stepName]; currentLevel.Add(step); foreach (var dependent in graph[stepName]) @@ -254,13 +270,33 @@ private static async Task ExecuteStepAsync(PipelineStep step, DeployingContext c } } - private sealed class PipelineRegistry(IEnumerable steps) : IPipelineRegistry + public override string ToString() { - private readonly Dictionary _stepsByName = steps.ToDictionary(s => s.Name); + if (_steps.Count == 0) + { + return "Pipeline: (empty)"; + } - public IEnumerable GetAllSteps() => _stepsByName.Values; + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"Pipeline with {_steps.Count} step(s):"); + + foreach (var step in _steps) + { + sb.Append(CultureInfo.InvariantCulture, $" - {step.Name}"); + + if (step.Dependencies.Count > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" [depends on: {string.Join(", ", step.Dependencies)}]"); + } + + if (step.RequiredBy.Count > 0) + { + sb.Append(CultureInfo.InvariantCulture, $" [required by: {string.Join(", ", step.RequiredBy)}]"); + } + + sb.AppendLine(); + } - public PipelineStep? GetStep(string name) => - _stepsByName.TryGetValue(name, out var step) ? step : null; + return sb.ToString(); } } diff --git a/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs b/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs deleted file mode 100644 index ea42852e923..00000000000 --- a/src/Aspire.Hosting/Pipelines/IPipelineOutputs.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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; - -namespace Aspire.Hosting.Pipelines; - -internal interface IPipelineOutputs -{ - void Set(string key, T value); - bool TryGet(string key, [NotNullWhen(true)] out T? value); -} diff --git a/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs b/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs deleted file mode 100644 index 51b596aa4e0..00000000000 --- a/src/Aspire.Hosting/Pipelines/IPipelineRegistry.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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 ASPIREPIPELINES001 - -namespace Aspire.Hosting.Pipelines; - -internal interface IPipelineRegistry -{ - IEnumerable GetAllSteps(); - PipelineStep? GetStep(string name); -} diff --git a/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs b/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs deleted file mode 100644 index ce1d69b8b26..00000000000 --- a/src/Aspire.Hosting/Pipelines/InMemoryPipelineOutputs.cs +++ /dev/null @@ -1,28 +0,0 @@ -// 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; - -namespace Aspire.Hosting.Pipelines; - -internal sealed class InMemoryPipelineOutputs : IPipelineOutputs -{ - private readonly Dictionary _outputs = new(); - - public void Set(string key, T value) - { - ArgumentNullException.ThrowIfNull(value); - _outputs[key] = value; - } - - public bool TryGet(string key, [NotNullWhen(true)] out T? value) - { - if (_outputs.TryGetValue(key, out var obj) && obj is T typed) - { - value = typed; - return true; - } - value = default; - return false; - } -} diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs index 6cca074d0b8..fd4a02fa2d1 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs @@ -14,30 +14,29 @@ namespace Aspire.Hosting.Pipelines; [Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public class PipelineStepAnnotation : IResourceAnnotation { - private readonly Func> _factory; + private readonly Func> _factory; /// /// Initializes a new instance of the class. /// /// A factory function that creates the pipeline step. - public PipelineStepAnnotation(Func factory) + public PipelineStepAnnotation(Func factory) { - _factory = context => [factory(context)]; + _factory = () => [factory()]; } /// /// Initializes a new instance of the class with a factory that creates multiple pipeline steps. /// /// A factory function that creates multiple pipeline steps. - public PipelineStepAnnotation(Func> factory) + public PipelineStepAnnotation(Func> factory) { _factory = factory; } /// - /// Creates pipeline steps using the provided deploying context. + /// Creates pipeline steps. /// - /// The deploying context. /// The created pipeline steps. - public IEnumerable CreateSteps(DeployingContext context) => _factory(context); + public IEnumerable CreateSteps() => _factory(); } diff --git a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs index 420cccb9613..e769971b9bc 100644 --- a/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs +++ b/src/Aspire.Hosting/Pipelines/WellKnownPipelineSteps.cs @@ -17,9 +17,9 @@ public static class WellKnownPipelineSteps public const string ProvisionInfrastructure = "provision-infra"; /// - /// The step that builds container images. + /// The step that builds compute resources. /// - public const string BuildImages = "build-images"; + public const string BuildCompute = "build-compute"; /// /// The step that deploys to compute infrastructure. diff --git a/src/Aspire.Hosting/Publishing/DeployingContext.cs b/src/Aspire.Hosting/Publishing/DeployingContext.cs index e2a512f9443..34c24d9e443 100644 --- a/src/Aspire.Hosting/Publishing/DeployingContext.cs +++ b/src/Aspire.Hosting/Publishing/DeployingContext.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -28,7 +27,7 @@ public sealed class DeployingContext( string? outputPath) { private IPublishingActivityReporter? _activityReporter; - private IPipelineOutputs? _pipelineOutputs; + private readonly Dictionary _pipelineOutputs = new(); /// /// Gets the distributed application model to be deployed. @@ -66,12 +65,6 @@ public sealed class DeployingContext( /// public string? OutputPath { get; } = outputPath; - /// - /// Gets the pipeline outputs manager for passing data between pipeline steps. - /// - internal IPipelineOutputs PipelineOutputs => _pipelineOutputs ??= - Services.GetService() ?? new InMemoryPipelineOutputs(); - /// /// Sets an output value that can be consumed by dependent pipeline steps. /// @@ -80,7 +73,8 @@ public sealed class DeployingContext( /// The value to store. public void SetPipelineOutput(string key, T value) { - PipelineOutputs.Set(key, value); + ArgumentNullException.ThrowIfNull(value); + _pipelineOutputs[key] = value; } /// @@ -92,7 +86,13 @@ public void SetPipelineOutput(string key, T value) /// True if the output was found and is of the expected type; otherwise, false. public bool TryGetPipelineOutput(string key, [NotNullWhen(true)] out T? value) { - return PipelineOutputs.TryGet(key, out value); + if (_pipelineOutputs.TryGetValue(key, out var obj) && obj is T typed) + { + value = typed; + return true; + } + value = default; + return false; } /// diff --git a/src/Aspire.Hosting/Publishing/Publisher.cs b/src/Aspire.Hosting/Publishing/Publisher.cs index 0c9d18523a5..23189e98f16 100644 --- a/src/Aspire.Hosting/Publishing/Publisher.cs +++ b/src/Aspire.Hosting/Publishing/Publisher.cs @@ -146,8 +146,8 @@ await statePathTask.CompleteAsync( Path.GetFullPath(options.Value.OutputPath) : null); // Execute the pipeline - it will collect steps from PipelineStepAnnotation on resources - var builder = serviceProvider.GetRequiredService(); - await builder.Pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false); + var pipeline = serviceProvider.GetRequiredService(); + await pipeline.ExecuteAsync(deployingContext).ConfigureAwait(false); } else { diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index eeab5ec0d75..b574112d668 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -241,7 +241,7 @@ public async Task ExecuteAsync_WithPipelineStepAnnotation_ExecutesAnnotatedSteps var executedSteps = new List(); var resource = builder.AddResource(new CustomResource("test-resource")) - .WithAnnotation(new PipelineStepAnnotation(context => new PipelineStep + .WithAnnotation(new PipelineStepAnnotation(() => new PipelineStep { Name = "annotated-step", Action = async (ctx) => @@ -273,7 +273,7 @@ public async Task ExecuteAsync_WithMultiplePipelineStepAnnotations_ExecutesAllAn var executedSteps = new List(); var resource = builder.AddResource(new CustomResource("test-resource")) - .WithAnnotation(new PipelineStepAnnotation(context => new[] + .WithAnnotation(new PipelineStepAnnotation(() => new[] { new PipelineStep { @@ -305,18 +305,15 @@ public async Task ExecuteAsync_WithMultiplePipelineStepAnnotations_ExecutesAllAn } [Fact] - public async Task ExecuteAsync_WithDuplicateStepNames_ThrowsInvalidOperationException() + public void AddStep_WithDuplicateStepNames_ThrowsInvalidOperationException() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); var pipeline = new DistributedApplicationPipeline(); pipeline.AddStep("step1", async (context) => await Task.CompletedTask); - pipeline.AddStep("step1", async (context) => await Task.CompletedTask); - - var context = CreateDeployingContext(builder.Build()); - var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); - Assert.Contains("Duplicate step name", ex.Message); + var ex = Assert.Throws(() => pipeline.AddStep("step1", async (context) => await Task.CompletedTask)); + Assert.Contains("A step with the name 'step1' has already been added", ex.Message); Assert.Contains("step1", ex.Message); } @@ -588,6 +585,19 @@ public void AddStep_WithInvalidRequiredByType_ThrowsArgumentException() Assert.Contains("The requiredBy parameter must be a string or IEnumerable", exception.Message); } + [Fact] + public void AddStep_WithDuplicateName_ThrowsInvalidOperationException() + { + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("step1", async (context) => await Task.CompletedTask); + + var exception = Assert.Throws(() => + pipeline.AddStep("step1", async (context) => await Task.CompletedTask)); + + Assert.Contains("A step with the name 'step1' has already been added to the pipeline", exception.Message); + } + private static DeployingContext CreateDeployingContext(DistributedApplication app) { return new DeployingContext( From ef3c52a9b3fbe1541995d21e999e069f7eb870a1 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 09:22:46 -0700 Subject: [PATCH 10/21] Fix Aspire.slnx --- Aspire.slnx | 1 - 1 file changed, 1 deletion(-) diff --git a/Aspire.slnx b/Aspire.slnx index e4d0434187a..d2ce1f43147 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -353,7 +353,6 @@ - From 6f43a0d32d50e8603481b76274cbde1d105f4f85 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 10:10:35 -0700 Subject: [PATCH 11/21] Add test for exception handling and improve cycle output --- .../DistributedApplicationPipeline.cs | 37 ++- .../DistributedApplicationPipelineTests.cs | 267 ++++++++++++++++++ 2 files changed, 300 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index d676807b590..c1f262a04bd 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.ExceptionServices; using System.Text; using Aspire.Hosting.ApplicationModel; @@ -116,8 +117,32 @@ public async Task ExecuteAsync(DeployingContext context) foreach (var level in levels) { - await Task.WhenAll(level.Select(step => - ExecuteStepAsync(step, context))).ConfigureAwait(false); + var tasks = level.Select(step => ExecuteStepAsync(step, context)).ToList(); + try + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch + { + // Collect all exceptions from failed tasks + var exceptions = tasks + .Where(t => t.IsFaulted) + .SelectMany(t => t.Exception?.InnerExceptions ?? Enumerable.Empty()) + .ToList(); + + if (exceptions.Count == 1) + { + ExceptionDispatchInfo.Capture(exceptions[0]).Throw(); + } + else if (exceptions.Count > 1) + { + throw new AggregateException( + $"Multiple pipeline steps failed at the same level: {string.Join(", ", exceptions.OfType().Select(e => e.Message))}", + exceptions); + } + + throw; + } } } @@ -250,8 +275,11 @@ private static List> ResolveDependencies( if (levels.Sum(l => l.Count) != steps.Count()) { + var processedSteps = new HashSet(levels.SelectMany(l => l.Select(s => s.Name))); + var stepsInCycle = steps.Where(s => !processedSteps.Contains(s.Name)).Select(s => s.Name).ToList(); + throw new InvalidOperationException( - "Circular dependency detected in pipeline steps"); + $"Circular dependency detected in pipeline steps: {string.Join(", ", stepsInCycle)}"); } return levels; @@ -265,8 +293,9 @@ private static async Task ExecuteStepAsync(PipelineStep step, DeployingContext c } catch (Exception ex) { + var exceptionInfo = ExceptionDispatchInfo.Capture(ex); throw new InvalidOperationException( - $"Step '{step.Name}' failed: {ex.Message}", ex); + $"Step '{step.Name}' failed: {ex.Message}", exceptionInfo.SourceException); } } diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index b574112d668..68550fe27fd 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -374,6 +374,8 @@ public async Task ExecuteAsync_WithCircularDependency_ThrowsInvalidOperationExce var ex = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); Assert.Contains("Circular dependency", ex.Message); + Assert.Contains("step1", ex.Message); + Assert.Contains("step2", ex.Message); } [Fact] @@ -598,6 +600,271 @@ public void AddStep_WithDuplicateName_ThrowsInvalidOperationException() Assert.Contains("A step with the name 'step1' has already been added to the pipeline", exception.Message); } + [Fact] + public async Task ExecuteAsync_WithDuplicateAnnotationStepNames_ThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var resource1 = builder.AddResource(new CustomResource("resource1")) + .WithAnnotation(new PipelineStepAnnotation(() => new PipelineStep + { + Name = "duplicate-step", + Action = async (ctx) => await Task.CompletedTask + })); + + var resource2 = builder.AddResource(new CustomResource("resource2")) + .WithAnnotation(new PipelineStepAnnotation(() => new PipelineStep + { + Name = "duplicate-step", + Action = async (ctx) => await Task.CompletedTask + })); + + var pipeline = new DistributedApplicationPipeline(); + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Duplicate step name", exception.Message); + Assert.Contains("duplicate-step", exception.Message); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleStepsFailingAtSameLevel_ThrowsAggregateException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("failing-step1", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error from step 1"); + }); + + pipeline.AddStep("failing-step2", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error from step 2"); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("Multiple pipeline steps failed", exception.Message); + Assert.Equal(2, exception.InnerExceptions.Count); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step1")); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step2")); + } + + [Fact] + public async Task ExecuteAsync_WithMixOfSuccessfulAndFailingStepsAtSameLevel_ThrowsAggregateException() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var successfulStepExecuted = false; + + pipeline.AddStep("successful-step", async (context) => + { + successfulStepExecuted = true; + await Task.CompletedTask; + }); + + pipeline.AddStep("failing-step1", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error from step 1"); + }); + + pipeline.AddStep("failing-step2", async (context) => + { + await Task.CompletedTask; + throw new NotSupportedException("Error from step 2"); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.True(successfulStepExecuted, "Successful step should have executed"); + Assert.Contains("Multiple pipeline steps failed", exception.Message); + Assert.Equal(2, exception.InnerExceptions.Count); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step1")); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step2")); + } + + [Fact] + public async Task ExecuteAsync_WithMultipleFailuresAtSameLevel_StopsExecutionOfNextLevel() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var nextLevelStepExecuted = false; + + pipeline.AddStep("failing-step1", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error from step 1"); + }); + + pipeline.AddStep("failing-step2", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error from step 2"); + }); + + pipeline.AddStep("next-level-step", async (context) => + { + nextLevelStepExecuted = true; + await Task.CompletedTask; + }, dependsOn: "failing-step1"); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.False(nextLevelStepExecuted, "Next level step should not have executed"); + Assert.Equal(2, exception.InnerExceptions.Count); + } + + [Fact] + public async Task ExecuteAsync_WithThreeStepsFailingAtSameLevel_CapturesAllExceptions() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("failing-step1", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error 1"); + }); + + pipeline.AddStep("failing-step2", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error 2"); + }); + + pipeline.AddStep("failing-step3", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Error 3"); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Equal(3, exception.InnerExceptions.Count); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step1")); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step2")); + Assert.Contains(exception.InnerExceptions, e => e.Message.Contains("failing-step3")); + } + + [Fact] + public async Task ExecuteAsync_WithDifferentExceptionTypesAtSameLevel_CapturesAllTypes() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("invalid-op-step", async (context) => + { + await Task.CompletedTask; + throw new InvalidOperationException("Invalid operation"); + }); + + pipeline.AddStep("not-supported-step", async (context) => + { + await Task.CompletedTask; + throw new NotSupportedException("Not supported"); + }); + + pipeline.AddStep("argument-step", async (context) => + { + await Task.CompletedTask; + throw new ArgumentException("Bad argument"); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Equal(3, exception.InnerExceptions.Count); + + var innerExceptions = exception.InnerExceptions.ToList(); + Assert.Contains(innerExceptions, e => e is InvalidOperationException && e.Message.Contains("invalid-op-step")); + Assert.Contains(innerExceptions, e => e is InvalidOperationException && e.Message.Contains("not-supported-step")); + Assert.Contains(innerExceptions, e => e is InvalidOperationException && e.Message.Contains("argument-step")); + } + + [Fact] + public async Task ExecuteAsync_WithFailingStep_PreservesOriginalStackTrace() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + pipeline.AddStep("failing-step", async (context) => + { + await Task.CompletedTask; + ThrowHelperMethod(); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + Assert.Contains("failing-step", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.Contains("ThrowHelperMethod", exception.InnerException.StackTrace); + } + + [Fact] + public async Task ExecuteAsync_WithParallelSuccessfulAndFailingSteps_OnlyFailuresReported() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + var pipeline = new DistributedApplicationPipeline(); + + var executedSteps = new List(); + + pipeline.AddStep("success1", async (context) => + { + executedSteps.Add("success1"); + await Task.CompletedTask; + }); + + pipeline.AddStep("fail1", async (context) => + { + executedSteps.Add("fail1"); + await Task.CompletedTask; + throw new InvalidOperationException("Failure 1"); + }); + + pipeline.AddStep("success2", async (context) => + { + executedSteps.Add("success2"); + await Task.CompletedTask; + }); + + pipeline.AddStep("fail2", async (context) => + { + executedSteps.Add("fail2"); + await Task.CompletedTask; + throw new InvalidOperationException("Failure 2"); + }); + + var context = CreateDeployingContext(builder.Build()); + + var exception = await Assert.ThrowsAsync(() => pipeline.ExecuteAsync(context)); + + // All steps should have attempted to execute + Assert.Contains("success1", executedSteps); + Assert.Contains("success2", executedSteps); + Assert.Contains("fail1", executedSteps); + Assert.Contains("fail2", executedSteps); + + // Only failures should be in the exception + Assert.Equal(2, exception.InnerExceptions.Count); + Assert.All(exception.InnerExceptions, e => Assert.IsType(e)); + } + + private static void ThrowHelperMethod() + { + throw new NotSupportedException("Test exception for stack trace"); + } + private static DeployingContext CreateDeployingContext(DistributedApplication app) { return new DeployingContext( From f3f7d417300cb7f32f260adbda6165b8cc6847dd Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 11:35:18 -0700 Subject: [PATCH 12/21] Remove website name from WebSiteAppContext --- .../AzureAppServiceWebsiteContext.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 93d0d8da57e..0030bdbf5f7 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -240,11 +240,6 @@ public void BuildWebSite(AzureResourceInfrastructure infra) }, }; - infra.Add(new ProvisioningOutput($"{Infrastructure.NormalizeBicepIdentifier(resource.Name)}_name", typeof(string)) - { - Value = webSite.Name - }); - // Defining the main container for the app service var mainContainer = new SiteContainer("mainContainer") { From f3677965df80ee89194a524c6b261a38d1adb08c Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 11:41:20 -0700 Subject: [PATCH 13/21] Add suppression to fix pack --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index f731b0f0b87..d961fac3e72 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -57,6 +57,13 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0006 + P:Aspire.Hosting.IDistributedApplicationBuilder.Pipeline + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0008 T:Aspire.Hosting.ApplicationModel.ParameterResource From e77f58453195a7a156e8e7ccdf5b0c5bf5b49619 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 11:52:28 -0700 Subject: [PATCH 14/21] Remove erroneous DI registration --- src/Aspire.Hosting/DistributedApplicationBuilder.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 4da5c90f222..261c7dcdc26 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -258,7 +258,6 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Core things _innerBuilder.Services.AddSingleton(sp => new DistributedApplicationModel(Resources)); - _innerBuilder.Services.AddSingleton(this); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); _innerBuilder.Services.AddHostedService(); From e89d36ae000f1eaf53c9b13a5d5d8cbf7c0bd6ee Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 12:11:33 -0700 Subject: [PATCH 15/21] Add pipeline property to test builder --- .../DistributedApplicationTestingBuilder.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 60c6a7bc4e1..97ba3575ba2 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -480,6 +480,9 @@ public interface IDistributedApplicationTestingBuilder : IDistributedApplication /// new IDistributedApplicationEventing Eventing => ((IDistributedApplicationBuilder)this).Eventing; + /// + new IDistributedApplicationPipeline Pipeline => ((IDistributedApplicationBuilder)this).Pipeline; + /// new IResourceCollection Resources => ((IDistributedApplicationBuilder)this).Resources; From 000d630d9b24e4a4a6242b74b7394abcb8c70ded Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 14:02:30 -0700 Subject: [PATCH 16/21] Address feedback --- .../AzureAppServiceWebsiteContext.cs | 2 +- .../DistributedApplicationPipeline.cs | 60 +++++++++++++++---- src/Aspire.Hosting/Pipelines/PipelineStep.cs | 16 ++--- .../Pipelines/PipelineStepAnnotation.cs | 2 +- .../Publishing/DeployingContext.cs | 3 +- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs index 0030bdbf5f7..5dccffdb0e2 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebsiteContext.cs @@ -213,7 +213,7 @@ public void BuildWebSite(AzureResourceInfrastructure infra) var acrMidParameter = environmentContext.Environment.ContainerRegistryManagedIdentityId.AsProvisioningParameter(infra); var acrClientIdParameter = environmentContext.Environment.ContainerRegistryClientId.AsProvisioningParameter(infra); var containerImage = AllocateParameter(new ContainerImageReference(Resource)); - + var webSite = new WebSite("webapp") { // Use the host name as the name of the web app diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index c1f262a04bd..e4a2aff441d 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -72,13 +72,13 @@ private static void AddRequiredBy(PipelineStep step, object requiredBy) { if (requiredBy is string stepName) { - step.IsRequiredBy(stepName); + step.RequiredBy(stepName); } else if (requiredBy is IEnumerable stepNames) { foreach (var name in stepNames) { - step.IsRequiredBy(name); + step.RequiredBy(name); } } else @@ -178,7 +178,7 @@ private static void ValidateSteps(IEnumerable steps) foreach (var step in steps) { - foreach (var dependency in step.Dependencies) + foreach (var dependency in step.DependsOnSteps) { if (!stepNames.Contains(dependency)) { @@ -187,7 +187,7 @@ private static void ValidateSteps(IEnumerable steps) } } - foreach (var requiredBy in step.RequiredBy) + foreach (var requiredBy in step.RequiredBySteps) { if (!stepNames.Contains(requiredBy)) { @@ -198,10 +198,19 @@ private static void ValidateSteps(IEnumerable steps) } } + /// + /// Resolves the dependencies among the steps and organizes them into levels for execution. + /// + /// The complete set of pipeline steps populated from annotations and the builder + /// A dictionary mapping step names to their corresponding step objects + /// A list of lists where each list contains the steps to be executed at the same level private static List> ResolveDependencies( IEnumerable steps, Dictionary stepsByName) { + // Initial a graph that represents a step and its dependencies + // and an inDegree map to count the number of dependencies that + // each step has. var graph = new Dictionary>(); var inDegree = new Dictionary(); @@ -211,9 +220,11 @@ private static List> ResolveDependencies( inDegree[step.Name] = 0; } + // Process all the `RequiredBy` relationships in the graph and adds + // the each `RequiredBy` step to the DependsOn list of the step that requires it. foreach (var step in steps) { - foreach (var requiredByStep in step.RequiredBy) + foreach (var requiredByStep in step.RequiredBySteps) { if (!graph.ContainsKey(requiredByStep)) { @@ -222,16 +233,18 @@ private static List> ResolveDependencies( } if (stepsByName.TryGetValue(requiredByStep, out var requiredByStepObj) && - !requiredByStepObj.Dependencies.Contains(step.Name)) + !requiredByStepObj.DependsOnSteps.Contains(step.Name)) { - requiredByStepObj.Dependencies.Add(step.Name); + requiredByStepObj.DependsOnSteps.Add(step.Name); } } } + // Now that the `DependsOn` lists are fully populated, we can build the graph + // and the inDegree map based only on the DependOnSteps list. foreach (var step in steps) { - foreach (var dependency in step.Dependencies) + foreach (var dependency in step.DependsOnSteps) { if (!graph.TryGetValue(dependency, out var dependents)) { @@ -244,11 +257,18 @@ private static List> ResolveDependencies( } } + // Perform a topological sort to determine the levels of execution and + // initialize a queue with all steps that have no dependencies (inDegree of 0) + // and can be executed immediately as part of the first level. var levels = new List>(); var queue = new Queue( inDegree.Where(kvp => kvp.Value == 0).Select(kvp => kvp.Key) ); + // Process the queue until all steps have been organized into levels. + // We start with the steps that have no dependencies and then iterate + // through all the steps that depend on them to build out the graph + // until no more steps are available to process. while (queue.Count > 0) { var currentLevel = new List(); @@ -260,6 +280,12 @@ private static List> ResolveDependencies( var step = stepsByName[stepName]; currentLevel.Add(step); + // For each dependent step, reduce its inDegree by 1 + // in each iteration since its dependencies have been + // processed. Once a dependent step has an inDegree + // of 0, it means all its dependencies have been + // processed and it can be added to the queue so we + // can process the next level of dependencies. foreach (var dependent in graph[stepName]) { inDegree[dependent]--; @@ -270,9 +296,19 @@ private static List> ResolveDependencies( } } + // Exhausting the queue means that we've resolved all + // steps that can run in parallel. levels.Add(currentLevel); } + // If the total number of steps in all levels does not equal + // the total number of steps in the pipeline, it indicates that + // there is a circular dependency in the graph. Steps are enqueued + // for processing into levels above when all their dependencies are + // resolved. When a cycle exists, the degrees of the steps in the cycle + // will never reach zero and won't be enqueued for processing so the + // total number of processed steps will be less than the total number + // of steps in the pipeline. if (levels.Sum(l => l.Count) != steps.Count()) { var processedSteps = new HashSet(levels.SelectMany(l => l.Select(s => s.Name))); @@ -313,14 +349,14 @@ public override string ToString() { sb.Append(CultureInfo.InvariantCulture, $" - {step.Name}"); - if (step.Dependencies.Count > 0) + if (step.DependsOnSteps.Count > 0) { - sb.Append(CultureInfo.InvariantCulture, $" [depends on: {string.Join(", ", step.Dependencies)}]"); + sb.Append(CultureInfo.InvariantCulture, $" [depends on: {string.Join(", ", step.DependsOnSteps)}]"); } - if (step.RequiredBy.Count > 0) + if (step.RequiredBySteps.Count > 0) { - sb.Append(CultureInfo.InvariantCulture, $" [required by: {string.Join(", ", step.RequiredBy)}]"); + sb.Append(CultureInfo.InvariantCulture, $" [required by: {string.Join(", ", step.RequiredBySteps)}]"); } sb.AppendLine(); diff --git a/src/Aspire.Hosting/Pipelines/PipelineStep.cs b/src/Aspire.Hosting/Pipelines/PipelineStep.cs index 19f752a6626..74a8218c0df 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStep.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStep.cs @@ -27,12 +27,12 @@ public class PipelineStep /// /// Gets the list of step names that this step depends on. /// - public List Dependencies { get; } = []; + public List DependsOnSteps { get; } = []; /// /// Gets the list of step names that require this step to complete before they can finish. /// - public List RequiredBy { get; } = []; + public List RequiredBySteps { get; } = []; /// /// Adds a dependency on another step. @@ -40,7 +40,7 @@ public class PipelineStep /// The name of the step to depend on. public void DependsOn(string stepName) { - Dependencies.Add(stepName); + DependsOnSteps.Add(stepName); } /// @@ -49,24 +49,24 @@ public void DependsOn(string stepName) /// The step to depend on. public void DependsOn(PipelineStep step) { - Dependencies.Add(step.Name); + DependsOnSteps.Add(step.Name); } /// /// Specifies that this step is required by another step. /// /// The name of the step that requires this step. - public void IsRequiredBy(string stepName) + public void RequiredBy(string stepName) { - RequiredBy.Add(stepName); + RequiredBySteps.Add(stepName); } /// /// Specifies that this step is required by another step. /// /// The step that requires this step. - public void IsRequiredBy(PipelineStep step) + public void RequiredBy(PipelineStep step) { - RequiredBy.Add(step.Name); + RequiredBySteps.Add(step.Name); } } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs index fd4a02fa2d1..9e3edbee92c 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepAnnotation.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.Pipelines; /// -/// An annotation that creates a pipeline step for a resource during deployment. +/// An annotation that creates pipeline steps for a resource during deployment. /// [Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public class PipelineStepAnnotation : IResourceAnnotation diff --git a/src/Aspire.Hosting/Publishing/DeployingContext.cs b/src/Aspire.Hosting/Publishing/DeployingContext.cs index 34c24d9e443..faf62ae962e 100644 --- a/src/Aspire.Hosting/Publishing/DeployingContext.cs +++ b/src/Aspire.Hosting/Publishing/DeployingContext.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; @@ -27,7 +28,7 @@ public sealed class DeployingContext( string? outputPath) { private IPublishingActivityReporter? _activityReporter; - private readonly Dictionary _pipelineOutputs = new(); + private readonly ConcurrentDictionary _pipelineOutputs = []; /// /// Gets the distributed application model to be deployed. From c4722b6ebd9412e3e55e3fca6ac221d35de09d1f Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 14:32:23 -0700 Subject: [PATCH 17/21] Remove DeployingCallbackAnnotation --- .../DeployingCallbackAnnotation.cs | 22 -- tests/Aspire.Hosting.Tests/PublishingTests.cs | 216 ------------------ 2 files changed, 238 deletions(-) delete mode 100644 src/Aspire.Hosting/ApplicationModel/DeployingCallbackAnnotation.cs diff --git a/src/Aspire.Hosting/ApplicationModel/DeployingCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DeployingCallbackAnnotation.cs deleted file mode 100644 index 20bc2b069ad..00000000000 --- a/src/Aspire.Hosting/ApplicationModel/DeployingCallbackAnnotation.cs +++ /dev/null @@ -1,22 +0,0 @@ -// 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; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// Represents a default deploying callback annotation for a distributed application model. -/// -/// -/// Initializes a new instance of the class. -/// -/// The deploying callback. -[Experimental("ASPIREPUBLISHERS001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class DeployingCallbackAnnotation(Func callback) : IResourceAnnotation -{ - /// - /// The deploying callback. - /// - public Func Callback { get; } = callback ?? throw new ArgumentNullException(nameof(callback)); -} diff --git a/tests/Aspire.Hosting.Tests/PublishingTests.cs b/tests/Aspire.Hosting.Tests/PublishingTests.cs index eeb62c23096..32c6e4645ea 100644 --- a/tests/Aspire.Hosting.Tests/PublishingTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishingTests.cs @@ -38,138 +38,6 @@ public void PublishCallsPublishingCallback() Assert.True(publishedCalled, "Publishing callback was not called."); } - [Fact] - public void PublishWithDeployFalseDoesNotCallDeployingCallback() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to false - builder.Configuration["Publishing:Deploy"] = "false"; - - var publishingCalled = false; - var deployingCalled = false; - - builder.AddContainer("cache", "redis") - .WithPublishingCallback(context => - { - publishingCalled = true; - return Task.CompletedTask; - }) - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - deployingCalled = true; - return Task.CompletedTask; - })); - - using var app = builder.Build(); - app.Run(); - - Assert.True(publishingCalled, "Publishing callback was not called."); - Assert.False(deployingCalled, "Deploying callback should not be called when Deploy is false."); - } - - [Fact] - public void PublishWithDeployTrueCallsDeployingCallbackOnly() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to true - builder.Configuration["Publishing:Deploy"] = "true"; - - var publishingCalled = false; - var deployingCalled = false; - - builder.AddContainer("cache", "redis") - .WithPublishingCallback(context => - { - return Task.CompletedTask; - }) - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - Assert.NotNull(context); - Assert.NotNull(context.Services); - Assert.True(context.CancellationToken.CanBeCanceled); - Assert.Equal(DistributedApplicationOperation.Publish, context.ExecutionContext.Operation); - Assert.Equal("default", context.ExecutionContext.PublisherName); - deployingCalled = true; - return Task.CompletedTask; - })); - - using var app = builder.Build(); - app.Run(); - - Assert.False(publishingCalled, "Publishing callback was called."); - Assert.True(deployingCalled, "Deploying callback was not called when Deploy is true."); - } - - [Fact] - public void MultipleResourcesWithDeployingCallbacks() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to true - builder.Configuration["Publishing:Deploy"] = "true"; - - var deployingCallbacks = new List(); - - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - deployingCallbacks.Add("cache"); - return Task.CompletedTask; - })); - - builder.AddContainer("db", "postgres") - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - deployingCallbacks.Add("db"); - return Task.CompletedTask; - })); - - using var app = builder.Build(); - app.Run(); - - Assert.Equal(2, deployingCallbacks.Count); - Assert.Contains("cache", deployingCallbacks); - Assert.Contains("db", deployingCallbacks); - } - - [Fact] - public void DeployingCallbackReceivesCorrectContext() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to true - builder.Configuration["Publishing:Deploy"] = "true"; - - var contextValidated = false; - - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - // Validate all properties of the DeployingContext - Assert.NotNull(context); - Assert.NotNull(context.Model); - Assert.NotNull(context.Services); - Assert.NotNull(context.Logger); - Assert.True(context.CancellationToken.CanBeCanceled); - Assert.Equal(DistributedApplicationOperation.Publish, context.ExecutionContext.Operation); - Assert.Equal("default", context.ExecutionContext.PublisherName); - - // Verify the model contains our resource - Assert.Single(context.Model.Resources); - Assert.Equal("cache", context.Model.Resources.Single().Name); - - contextValidated = true; - return Task.CompletedTask; - })); - - using var app = builder.Build(); - app.Run(); - - Assert.True(contextValidated, "DeployingContext validation failed."); - } - [Fact] public void PublishingOptionsDeployPropertyDefaultsToFalse() { @@ -202,88 +70,4 @@ public void PublishingOptionsDeployPropertyCanBeSetViaCommandLine() Assert.True(publishingOptions.Value.Deploy, "Deploy should be true when set via command line."); } - [Fact] - public async Task DeployingCallback_Throws_PropagatesException() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - builder.Configuration["Publishing:Deploy"] = "true"; - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(_ => throw new InvalidOperationException("Deploy failed!"))); - using var app = builder.Build(); - var ex = await Assert.ThrowsAsync(() => app.RunAsync()); - Assert.Contains("Deploy failed!", ex.Message); - } - - [Fact] - public void DeployingCallback_OnlyLastAnnotationIsUsed() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - builder.Configuration["Publishing:Deploy"] = "true"; - var called = string.Empty; - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(_ => { called = "first"; return Task.CompletedTask; })) - .WithAnnotation(new DeployingCallbackAnnotation(_ => { called = "second"; return Task.CompletedTask; })); - using var app = builder.Build(); - app.Run(); - Assert.Equal("second", called); - } - - [Fact] - public void DeployingContextActivityReporterProperty() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to true - builder.Configuration["Publishing:Deploy"] = "true"; - - var activityReporterAccessed = false; - - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(context => - { - // Verify that ActivityReporter property is accessible and not null - Assert.NotNull(context.ActivityReporter); - Assert.IsAssignableFrom(context.ActivityReporter); - - // Verify that accessing it multiple times returns the same instance (lazy initialization) - var reporter1 = context.ActivityReporter; - var reporter2 = context.ActivityReporter; - Assert.Same(reporter1, reporter2); - - activityReporterAccessed = true; - return Task.CompletedTask; - })); - - using var app = builder.Build(); - app.Run(); - - Assert.True(activityReporterAccessed, "ActivityReporter property was not tested."); - } - - [Fact] - public void DeployingCallback_ParametersAreResolvedBeforeCallbacks() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default"); - - // Explicitly set Deploy to true - builder.Configuration["Publishing:Deploy"] = "true"; - builder.Configuration["Parameters:test-param"] = "test-value"; - - // Add a parameter that should be resolved before the callback runs - var param = builder.AddParameter("test-param"); - - var parameterValueInCallback = string.Empty; - - builder.AddContainer("cache", "redis") - .WithAnnotation(new DeployingCallbackAnnotation(async context => - { - // At this point, the parameter should already be resolved - parameterValueInCallback = await param.Resource.GetValueAsync(default); - })); - - using var app = builder.Build(); - app.Run(); - - Assert.Equal("test-value", parameterValueInCallback); - } } From 2424e6fb1fd9d09f987692c2ebe865e3fe09292e Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 14:50:38 -0700 Subject: [PATCH 18/21] Suppress warning about removed DeloyingCallbackAnnotation --- src/Aspire.Hosting/CompatibilitySuppressions.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index d961fac3e72..b2fa6eedc90 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,6 +1,13 @@  + + CP0001 + T:Aspire.Hosting.ApplicationModel.DeployingCallbackAnnotation + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0002 M:Aspire.Hosting.IInteractionService.PromptInputsAsync(System.String,System.String,System.Collections.Generic.IReadOnlyList{Aspire.Hosting.InteractionInput},Aspire.Hosting.InputsDialogInteractionOptions,System.Threading.CancellationToken) From cb6156038f39f08e3ccf35eaa8717c3688a1941a Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 19:02:50 -0700 Subject: [PATCH 19/21] Fix external endpoint handling after rebase --- src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 364ad9dfdaf..5e3a97a875a 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -579,8 +579,12 @@ private static string TryGetComputeResourceEndpoint(IResource computeResource, I if (azureComputeEnv is AzureProvisioningResource provisioningResource && provisioningResource.Outputs.TryGetValue("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN", out var domainValue)) { - var endpoint = $"https://{computeResource.Name.ToLowerInvariant()}.{domainValue}"; - return $" to {endpoint}"; + // Only produce endpoints for resources that have external endpoints + if (computeResource.TryGetEndpoints(out var endpoints) && endpoints.Any(e => e.IsExternal)) + { + var endpoint = $"https://{computeResource.Name.ToLowerInvariant()}.{domainValue}"; + return $" to {endpoint}"; + } } return string.Empty; From 2d4d80a6997d764bc7081d516c355fa02dfe60bf Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 10 Oct 2025 21:17:46 -0700 Subject: [PATCH 20/21] Remove output related APIs --- .../AzureEnvironmentResource.cs | 33 +++++++++---- .../Publishing/DeployingContext.cs | 49 ------------------- 2 files changed, 24 insertions(+), 58 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 5e3a97a875a..6f2e4e46dba 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -61,18 +61,27 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet Annotations.Add(new PipelineStepAnnotation(() => { + ProvisioningContext? provisioningContext = null; + var validateStep = new PipelineStep { Name = "validate-azure-cli-login", Action = ctx => ValidateAzureCliLoginAsync(ctx) }; + var createContextStep = new PipelineStep + { + Name = "create-provisioning-context", + Action = async ctx => provisioningContext = await CreateProvisioningContextAsync(ctx).ConfigureAwait(false) + }; + createContextStep.DependsOn(validateStep); + var provisionStep = new PipelineStep { Name = WellKnownPipelineSteps.ProvisionInfrastructure, - Action = ctx => ProvisionAzureBicepResourcesAsync(ctx) + Action = ctx => ProvisionAzureBicepResourcesAsync(ctx, provisioningContext!) }; - provisionStep.DependsOn(validateStep); + provisionStep.DependsOn(createContextStep); var buildStep = new PipelineStep { @@ -91,7 +100,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var deployStep = new PipelineStep { Name = WellKnownPipelineSteps.DeployCompute, - Action = ctx => DeployComputeResourcesAsync(ctx) + Action = ctx => DeployComputeResourcesAsync(ctx, provisioningContext!) }; deployStep.DependsOn(pushStep); deployStep.DependsOn(provisionStep); @@ -103,7 +112,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet }; printDashboardUrlStep.DependsOn(deployStep); - return [validateStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep]; + return [validateStep, createContextStep, provisionStep, buildStep, pushStep, deployStep, printDashboardUrlStep]; })); Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); @@ -161,11 +170,10 @@ await validationStep.FailAsync( } } - private static async Task ProvisionAzureBicepResourcesAsync(DeployingContext context) + private static async Task CreateProvisioningContextAsync(DeployingContext context) { var provisioningContextProvider = context.Services.GetRequiredService(); var deploymentStateManager = context.Services.GetRequiredService(); - var bicepProvisioner = context.Services.GetRequiredService(); var configuration = context.Services.GetRequiredService(); var userSecrets = await deploymentStateManager.LoadStateAsync(context.CancellationToken) @@ -182,7 +190,14 @@ await deploymentStateManager.SaveStateAsync( context.CancellationToken).ConfigureAwait(false); } - context.SetPipelineOutput("ProvisioningContext", provisioningContext); + return provisioningContext; + } + + private static async Task ProvisionAzureBicepResourcesAsync(DeployingContext context, ProvisioningContext provisioningContext) + { + var bicepProvisioner = context.Services.GetRequiredService(); + var deploymentStateManager = context.Services.GetRequiredService(); + var configuration = context.Services.GetRequiredService(); var bicepResources = context.Model.Resources.OfType() .Where(r => !r.IsExcludedFromPublish()) @@ -255,6 +270,7 @@ await resourceTask.CompleteAsync( await Task.WhenAll(provisioningTasks).ConfigureAwait(false); } + var clearCache = configuration.GetValue("Publishing:ClearCache"); if (!clearCache) { await deploymentStateManager.SaveStateAsync( @@ -332,9 +348,8 @@ await PushImagesToAllRegistriesAsync(resourcesByRegistry, context, containerImag .ConfigureAwait(false); } - private static async Task DeployComputeResourcesAsync(DeployingContext context) + private static async Task DeployComputeResourcesAsync(DeployingContext context, ProvisioningContext provisioningContext) { - var provisioningContext = context.GetPipelineOutput("ProvisioningContext"); var bicepProvisioner = context.Services.GetRequiredService(); var computeResources = context.Model.GetComputeResources().ToList(); diff --git a/src/Aspire.Hosting/Publishing/DeployingContext.cs b/src/Aspire.Hosting/Publishing/DeployingContext.cs index faf62ae962e..a2aea30a1d9 100644 --- a/src/Aspire.Hosting/Publishing/DeployingContext.cs +++ b/src/Aspire.Hosting/Publishing/DeployingContext.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.Publishing; using Microsoft.Extensions.DependencyInjection; @@ -28,7 +27,6 @@ public sealed class DeployingContext( string? outputPath) { private IPublishingActivityReporter? _activityReporter; - private readonly ConcurrentDictionary _pipelineOutputs = []; /// /// Gets the distributed application model to be deployed. @@ -65,51 +63,4 @@ public sealed class DeployingContext( /// Gets the output path for deployment artifacts. /// public string? OutputPath { get; } = outputPath; - - /// - /// Sets an output value that can be consumed by dependent pipeline steps. - /// - /// The type of the output value. - /// The key to identify the output. - /// The value to store. - public void SetPipelineOutput(string key, T value) - { - ArgumentNullException.ThrowIfNull(value); - _pipelineOutputs[key] = value; - } - - /// - /// Attempts to retrieve an output value set by a previous pipeline step. - /// - /// The expected type of the output value. - /// The key identifying the output. - /// The retrieved value if found. - /// True if the output was found and is of the expected type; otherwise, false. - public bool TryGetPipelineOutput(string key, [NotNullWhen(true)] out T? value) - { - if (_pipelineOutputs.TryGetValue(key, out var obj) && obj is T typed) - { - value = typed; - return true; - } - value = default; - return false; - } - - /// - /// Retrieves an output value set by a previous pipeline step. - /// - /// The expected type of the output value. - /// The key identifying the output. - /// The output value. - /// Thrown when the output is not found or is not of the expected type. - public T GetPipelineOutput(string key) - { - if (!TryGetPipelineOutput(key, out var value)) - { - throw new InvalidOperationException( - $"Pipeline output '{key}' not found or is not of type {typeof(T).Name}"); - } - return value; - } } From c981c59273e93ccbc531f0df88503ee9867c51c2 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Sat, 11 Oct 2025 09:20:32 -0700 Subject: [PATCH 21/21] Fix logging for deployment steps on analyze model --- .../DistributedApplicationPipeline.cs | 2 + src/Aspire.Hosting/Publishing/Publisher.cs | 49 ++-- .../DistributedApplicationPipelineTests.cs | 255 ++++++++++++++++++ 3 files changed, 283 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index e4a2aff441d..e878254dadb 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -17,6 +17,8 @@ internal sealed class DistributedApplicationPipeline : IDistributedApplicationPi { private readonly List _steps = []; + public bool HasSteps => _steps.Count > 0; + public void AddStep(string name, Func action, object? dependsOn = null, diff --git a/src/Aspire.Hosting/Publishing/Publisher.cs b/src/Aspire.Hosting/Publishing/Publisher.cs index 23189e98f16..5cdd3ec1eaa 100644 --- a/src/Aspire.Hosting/Publishing/Publisher.cs +++ b/src/Aspire.Hosting/Publishing/Publisher.cs @@ -80,28 +80,41 @@ public async Task PublishAsync(DistributedApplicationModel model, CancellationTo cancellationToken) .ConfigureAwait(false); - var targetResources = new List(); + string message; + CompletionState state; - foreach (var resource in model.Resources) + if (options.Value.Deploy) { - if (options.Value.Deploy) + var hasResourcesWithSteps = model.Resources.Any(r => r.HasAnnotationOfType()); + var pipeline = serviceProvider.GetRequiredService(); + var hasDirectlyRegisteredSteps = pipeline is DistributedApplicationPipeline concretePipeline && concretePipeline.HasSteps; + + if (!hasResourcesWithSteps && !hasDirectlyRegisteredSteps) { - if (resource.HasAnnotationOfType()) - { - targetResources.Add(resource); - } + message = "No deployment steps found in the application pipeline."; + state = CompletionState.CompletedWithError; } else { - if (resource.HasAnnotationOfType()) - { - targetResources.Add(resource); - } + message = "Found deployment steps in the application pipeline."; + state = CompletionState.Completed; } - } + else + { + var targetResources = model.Resources.Where(r => r.HasAnnotationOfType()).ToList(); - var (message, state) = GetTaskInfo(targetResources, options.Value.Deploy); + if (targetResources.Count == 0) + { + message = "No resources in the distributed application model support publishing."; + state = CompletionState.CompletedWithError; + } + else + { + message = $"Found {targetResources.Count} resources that support publishing. ({string.Join(", ", targetResources.Select(r => r.GetType().Name))})"; + state = CompletionState.Completed; + } + } await task.CompleteAsync( message, @@ -156,14 +169,4 @@ await statePathTask.CompleteAsync( await publishingContext.WriteModelAsync(model).ConfigureAwait(false); } } - - private static (string Message, CompletionState State) GetTaskInfo(List targetResources, bool isDeploy) - { - var operation = isDeploy ? "deployment" : "publishing"; - return targetResources.Count switch - { - 0 => ($"No resources in the distributed application model support {operation}.", CompletionState.CompletedWithError), - _ => ($"Found {targetResources.Count} resources that support {operation}. ({string.Join(", ", targetResources.Select(r => r.GetType().Name))})", CompletionState.Completed) - }; - } } diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 68550fe27fd..7de73823b31 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs @@ -6,7 +6,10 @@ #pragma warning disable IDE0005 using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Backchannel; using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Publishing; +using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -860,6 +863,258 @@ public async Task ExecuteAsync_WithParallelSuccessfulAndFailingSteps_OnlyFailure Assert.All(exception.InnerExceptions, e => Assert.IsType(e)); } + [Fact] + public async Task PublishAsync_Deploy_WithNoResourcesAndNoPipelineSteps_ReturnsError() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + + // Act + await publisher.PublishAsync(app.Services.GetRequiredService(), CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundErrorActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + activity.Data.IsError && + activity.Data.CompletionMessage == "No deployment steps found in the application pipeline.") + { + foundErrorActivity = true; + break; + } + } + + Assert.True(foundErrorActivity, "Expected to find a task activity with error about no deployment steps found"); + } + + [Fact] + public async Task PublishAsync_Deploy_WithNoResourcesButHasPipelineSteps_Succeeds() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("test-step", async (context) => await Task.CompletedTask); + + builder.Services.AddSingleton(pipeline); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + var model = app.Services.GetRequiredService(); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundSuccessActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + !activity.Data.IsError && + activity.Data.CompletionMessage == "Found deployment steps in the application pipeline.") + { + foundSuccessActivity = true; + break; + } + } + + Assert.True(foundSuccessActivity, "Expected to find a task activity with message about deployment steps in the application pipeline"); + } + + [Fact] + public async Task PublishAsync_Deploy_WithResourcesAndPipelineSteps_ShowsStepsMessage() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var resource = builder.AddResource(new CustomResource("test-resource")) + .WithAnnotation(new PipelineStepAnnotation(() => new PipelineStep + { + Name = "annotated-step", + Action = async (ctx) => await Task.CompletedTask + })); + + var pipeline = new DistributedApplicationPipeline(); + pipeline.AddStep("direct-step", async (context) => await Task.CompletedTask); + + builder.Services.AddSingleton(pipeline); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + var model = app.Services.GetRequiredService(); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundSuccessActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + !activity.Data.IsError && + activity.Data.CompletionMessage == "Found deployment steps in the application pipeline.") + { + foundSuccessActivity = true; + break; + } + } + + Assert.True(foundSuccessActivity, "Expected to find a task activity with message about deployment steps in the application pipeline"); + } + + [Fact] + public async Task PublishAsync_Deploy_WithOnlyResources_ShowsStepsMessage() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: true); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var resource = builder.AddResource(new CustomResource("test-resource")) + .WithAnnotation(new PipelineStepAnnotation(() => new PipelineStep + { + Name = "annotated-step", + Action = async (ctx) => await Task.CompletedTask + })); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + var model = app.Services.GetRequiredService(); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundSuccessActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + !activity.Data.IsError && + activity.Data.CompletionMessage == "Found deployment steps in the application pipeline.") + { + foundSuccessActivity = true; + break; + } + } + + Assert.True(foundSuccessActivity, "Expected to find a task activity with message about deployment steps in the application pipeline"); + } + + [Fact] + public async Task PublishAsync_Publish_WithNoResources_ReturnsError() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: false); + + builder.Services.Configure(options => + { + options.OutputPath = Path.GetTempPath(); + }); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + var model = app.Services.GetRequiredService(); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundErrorActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + activity.Data.IsError && + activity.Data.CompletionMessage == "No resources in the distributed application model support publishing.") + { + foundErrorActivity = true; + break; + } + } + + Assert.True(foundErrorActivity, "Expected to find a task activity with error about no resources supporting publishing"); + } + + [Fact] + public async Task PublishAsync_Publish_WithResources_ShowsResourceCount() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, publisher: "default", isDeploy: false); + + builder.Services.Configure(options => + { + options.OutputPath = Path.GetTempPath(); + }); + + var interactionService = PublishingActivityReporterTests.CreateInteractionService(); + var reporter = new PublishingActivityReporter(interactionService, NullLogger.Instance); + + builder.Services.AddSingleton(reporter); + + var resource = builder.AddResource(new CustomResource("test-resource")) + .WithAnnotation(new PublishingCallbackAnnotation(async (context) => await Task.CompletedTask)); + + var app = builder.Build(); + var publisher = app.Services.GetRequiredKeyedService("default"); + var model = app.Services.GetRequiredService(); + + // Act + await publisher.PublishAsync(model, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + var foundSuccessActivity = false; + + while (activityReader.TryRead(out var activity)) + { + if (activity.Type == PublishingActivityTypes.Task && + !activity.Data.IsError && + activity.Data.CompletionMessage?.StartsWith("Found 1 resources that support publishing.") == true) + { + foundSuccessActivity = true; + break; + } + } + + Assert.True(foundSuccessActivity, "Expected to find a task activity with message about resources supporting publishing"); + } + private static void ThrowHelperMethod() { throw new NotSupportedException("Test exception for stack trace");