File: AzureDeployingContext.cs
Web Access
Project: src\src\Aspire.Hosting.Azure\Aspire.Hosting.Azure.csproj (Aspire.Hosting.Azure)
// 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.
 
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 Aspire.Hosting.ApplicationModel;
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.Dcp.Process;
 
namespace Aspire.Hosting.Azure;
 
internal sealed class AzureDeployingContext(
    IProvisioningContextProvider provisioningContextProvider,
    IUserSecretsManager userSecretsManager,
    IBicepProvisioner bicepProvisioner,
    IPublishingActivityReporter activityReporter,
    IResourceContainerImageBuilder containerImageBuilder)
{
    public async Task DeployModelAsync(AzureEnvironmentResource resource, DistributedApplicationModel model, CancellationToken cancellationToken = default)
    {
        var userSecrets = await userSecretsManager.LoadUserSecretsAsync(cancellationToken).ConfigureAwait(false);
        var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(userSecrets, cancellationToken).ConfigureAwait(false);
 
        if (resource.PublishingContext is null)
        {
            throw new InvalidOperationException($"Publishing context is not initialized. Please ensure that the {nameof(AzurePublishingContext)} has been initialized before deploying.");
        }
 
        // Step 1: Provision main Azure infrastructure (compute environment, resources, container registry)
        if (!await TryProvisionAzureInfrastructure(resource, 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;
        }
 
        // 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<bool> TryProvisionAzureInfrastructure(AzureEnvironmentResource resource, ProvisioningContext provisioningContext, CancellationToken cancellationToken)
    {
        var deployingStep = await activityReporter.CreateStepAsync("Deploying to Azure", cancellationToken).ConfigureAwait(false);
        await using (deployingStep.ConfigureAwait(false))
        {
            try
            {
                foreach (var (parameterResource, provisioningParameter) in resource.PublishingContext!.ParameterLookup)
                {
                    if (parameterResource == resource.Location)
                    {
                        resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Location.Name;
                    }
                    else if (parameterResource == resource.ResourceGroupName)
                    {
                        resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.ResourceGroup.Name;
                    }
                    else if (parameterResource == resource.PrincipalId)
                    {
                        resource.Parameters[provisioningParameter.BicepIdentifier] = provisioningContext.Principal.Id.ToString();
                    }
                    else
                    {
                        // TODO: Prompt here.
                        await deployingStep.FailAsync("Deployment contains unresolvable parameters.", cancellationToken).ConfigureAwait(false);
                    }
                }
 
                // Set the scope for this resource to indicate that it is a subscription-level resource.
                resource.Scope = new AzureBicepResourceScope(provisioningContext.ResourceGroup.Name, provisioningContext.Subscription.Id.ToString());
 
                var azureTask = await deployingStep.CreateTaskAsync("Provisioning Azure environment", cancellationToken).ConfigureAwait(false);
                await using (azureTask.ConfigureAwait(false))
                {
                    await bicepProvisioner.GetOrCreateResourceAsync(resource, provisioningContext, cancellationToken).ConfigureAwait(false);
                    PropagateOutputsToResources(resource);
                }
            }
            catch (Exception ex)
            {
                var errorMessage = ex switch
                {
                    RequestFailedException requestEx => $"Deployment failed: {ExtractDetailedErrorMessage(requestEx)}",
                    _ => $"Deployment failed: {ex.Message}"
                };
 
                await deployingStep.FailAsync(errorMessage, cancellationToken).ConfigureAwait(false);
                return false;
            }
        }
        return true;
    }
 
    private async Task<bool> TryDeployContainerImages(DistributedApplicationModel model, CancellationToken cancellationToken)
    {
        var computeResources = model.GetComputeResources();
 
        if (!computeResources.Any())
        {
            return false;
        }
 
        // Group resources by their deployment target (container registry) since each compute
        // environment will provision a different container registry
        var resourcesByRegistry = new Dictionary<IContainerRegistry, List<IResource>>();
 
        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);
            }
        }
 
        foreach (var (registry, resources) in resourcesByRegistry)
        {
            await ProcessResourcesForRegistry(registry, resources, cancellationToken).ConfigureAwait(false);
        }
 
        return true;
    }
 
    private async Task<bool> TryDeployComputeResources(DistributedApplicationModel model,
        ProvisioningContext provisioningContext, CancellationToken cancellationToken)
    {
        var computeResources = model.GetComputeResources();
 
        if (!computeResources.Any())
        {
            return false;
        }
 
        var computeStep = await activityReporter.CreateStepAsync("Deploying compute resources", cancellationToken).ConfigureAwait(false);
        await using (computeStep.ConfigureAwait(false))
        {
            try
            {
                foreach (var computeResource in computeResources)
                {
                    await DeployComputeResource(computeStep, computeResource, provisioningContext, cancellationToken).ConfigureAwait(false);
                }
 
                await computeStep.CompleteAsync($"Successfully deployed {computeResources.Count()} compute resources", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                await computeStep.CompleteAsync($"Compute resource deployment failed: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false);
                throw;
            }
        }
 
        return true;
    }
 
    private async Task DeployComputeResource(IPublishingStep parentStep, IResource computeResource, ProvisioningContext provisioningContext, CancellationToken cancellationToken)
    {
        var resourceTask = await parentStep.CreateTaskAsync($"Deploying {computeResource.Name}", cancellationToken).ConfigureAwait(false);
        await using (resourceTask.ConfigureAwait(false))
        {
            try
            {
                if (computeResource.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var 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)
            {
                await resourceTask.CompleteAsync($"Failed to deploy {computeResource.Name}: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    private static bool TryGetContainerRegistry(IResource computeResource, [NotNullWhen(true)] out IContainerRegistry? containerRegistry)
    {
        if (computeResource.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var deploymentTarget) &&
            deploymentTarget.ContainerRegistry is { } registry)
        {
            containerRegistry = registry;
            return true;
        }
 
        containerRegistry = null;
        return false;
    }
 
    private async Task ProcessResourcesForRegistry(IContainerRegistry registry, List<IResource> resources, CancellationToken cancellationToken)
    {
        await containerImageBuilder.BuildImagesAsync(
            resources,
            new ContainerBuildOptions
            {
                TargetPlatform = ContainerTargetPlatform.LinuxAmd64
            },
            cancellationToken).ConfigureAwait(false);
 
        var registryName = await registry.Name.GetValueAsync(cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to retrieve container registry information.");
        var acrStep = await activityReporter.CreateStepAsync($"Pushing images to {registryName}", cancellationToken).ConfigureAwait(false);
        await using (acrStep.ConfigureAwait(false))
        {
            try
            {
                await AuthenticateToAcr(acrStep, registryName, cancellationToken).ConfigureAwait(false);
                await PushImageToAcr(acrStep, resources, cancellationToken).ConfigureAwait(false);
                await acrStep.CompleteAsync($"Successfully pushed {resources.Count} images to {registryName}", CompletionState.Completed, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                await acrStep.CompleteAsync($"Failed to push images to {registryName}: {ex.Message}", CompletionState.CompletedWithError, cancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    private static void PropagateOutputsToResources(AzureEnvironmentResource resource)
    {
        foreach (var (populatedMainOutputName, populatedMainOutputValue) in resource.Outputs)
        {
            if (resource.PublishingContext!.ReverseOutputLookup.TryGetValue(populatedMainOutputName, out var outputRef))
            {
                outputRef.Resource.Outputs[outputRef.Name] = populatedMainOutputValue;
            }
        }
    }
 
    private static async Task AuthenticateToAcr(IPublishingStep parentStep, string registryName, CancellationToken cancellationToken)
    {
        var loginTask = await parentStep.CreateTaskAsync($"Logging in to container registry", 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
            };
 
            var (pendingResult, processDisposable) = ProcessUtil.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);
                }
            }
        }
    }
 
    private static async Task PushImageToAcr(IPublishingStep parentStep, IEnumerable<IResource> resources, CancellationToken cancellationToken)
    {
        foreach (var resource in resources)
        {
            if (!resource.RequiresImageBuildAndPush())
            {
                continue;
            }
            var localImageName = resource.Name.ToLowerInvariant();
            IValueProvider cir = new ContainerImageReference(resource);
            var targetTag = await cir.GetValueAsync(cancellationToken).ConfigureAwait(false);
 
            var pushTask = await parentStep.CreateTaskAsync($"Pushing {resource.Name}", 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;
                }
            }
        }
    }
 
    private static async Task TagAndPushImage(string localTag, string targetTag, CancellationToken cancellationToken)
    {
        await RunDockerCommand($"tag {localTag} {targetTag}", cancellationToken).ConfigureAwait(false);
        await RunDockerCommand($"push {targetTag}", cancellationToken).ConfigureAwait(false);
    }
 
    private static async Task RunDockerCommand(string arguments, CancellationToken cancellationToken)
    {
        var dockerSpec = new ProcessSpec("docker")
        {
            Arguments = arguments
        };
 
        var (pendingResult, processDisposable) = ProcessUtil.Run(dockerSpec);
        await using (processDisposable.ConfigureAwait(false))
        {
            await pendingResult.WaitAsync(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;
    }
 
    // 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}";
                }
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// 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
    /// </summary>
    /// <param name="requestEx">The Azure RequestFailedException containing the error response</param>
    /// <returns>The most specific error message found, or the original exception message if parsing fails</returns>
    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;
    }
}