File: AzureAppServiceEnvironmentResource.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.AppService\Aspire.Hosting.Azure.AppService.csproj (Aspire.Hosting.Azure.AppService)
// 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
#pragma warning disable ASPIREAZURE001
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.AppService;
using Azure.Core;
using Aspire.Hosting.Pipelines;
using Azure.Provisioning;
using Azure.Provisioning.AppService;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Represents an Azure App Service Environment resource.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
public class AzureAppServiceEnvironmentResource :
    AzureProvisioningResource,
    IAzureComputeEnvironmentResource,
    IAzureContainerRegistry,
    IAzureDelegatedSubnetResource
#pragma warning restore CS0618 // Type or member is obsolete
#pragma warning restore ASPIREAZURE003
{
    /// <inheritdoc />
    string IAzureDelegatedSubnetResource.DelegatedSubnetServiceName => AzureSubnetServiceDelegations.AppServiceEnvironments;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AzureAppServiceEnvironmentResource"/> class.
    /// </summary>
    /// <param name="name">The name of the Azure App Service Environment.</param>
    /// <param name="configureInfrastructure">The callback to configure the Azure infrastructure for this resource.</param>
    public AzureAppServiceEnvironmentResource(string name, Action<AzureResourceInfrastructure> configureInfrastructure)
        : base(name, configureInfrastructure)
    {
        // Add pipeline step annotation to create steps and expand deployment target steps
        Annotations.Add(new PipelineStepAnnotation(async (factoryContext) =>
        {
            var model = factoryContext.PipelineContext.Model;
            var steps = new List<PipelineStep>();
 
            // Add prepare-azure-app-service-{name} step that materializes deployment targets
            // for compute resources targeted to this environment. Runs during BeforeStart so that
            // BeforeStartEvent subscribers (and downstream code) can observe the deployment targets.
            var prepareStep = new PipelineStep
            {
                Name = $"prepare-azure-app-service-{name}",
                Description = $"Prepares Azure App Service deployment targets for {name}.",
                Action = ctx => PrepareDeploymentTargetsAsync(ctx),
                DependsOnSteps = [AzureEnvironmentResource.PrepareResourcesStepName, WellKnownPipelineSteps.ValidateComputeEnvironments],
                RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
            };
 
            steps.Add(prepareStep);
 
            // Add validation step that checks for environment variable name issues
            // This runs early and provides user-friendly error messages through the activity reporter
            var validateStep = new PipelineStep
            {
                Name = $"validate-appservice-config-{name}",
                Description = $"Validates Azure App Service configuration for {name}.",
                Action = ctx => ValidateAppServiceConfigurationAsync(ctx, model),
                RequiredBySteps = [WellKnownPipelineSteps.Publish],
                DependsOnSteps = [WellKnownPipelineSteps.PublishPrereq]
            };
 
            steps.Add(validateStep);
 
            if (EnableDashboard)
            {
                // The dashboard output is only emitted when the dashboard is provisioned.
                // Avoid registering the summary step when WithDashboard(false) is used,
                // otherwise deploy succeeds and then fails while trying to read a missing output.
                var printDashboardUrlStep = new PipelineStep
                {
                    Name = $"print-dashboard-url-{name}",
                    Description = $"Prints the deployment summary and dashboard URL for {name}.",
                    Action = ctx => PrintDashboardUrlAsync(ctx),
                    Tags = ["print-summary"],
                    DependsOnSteps = [AzureEnvironmentResource.ProvisionInfrastructureStepName],
                    RequiredBySteps = [WellKnownPipelineSteps.Deploy]
                };
 
                steps.Add(printDashboardUrlStep);
            }
 
            // Expand deployment target steps for all compute resources
            // This ensures the push/provision steps from deployment targets are included in the pipeline
            foreach (var computeResource in model.GetComputeResources())
            {
                var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
 
                if (deploymentTarget != null && deploymentTarget.TryGetAnnotationsOfType<PipelineStepAnnotation>(out var annotations))
                {
                    // Resolve the deployment target's PipelineStepAnnotation and expand its steps
                    // We do this because the deployment target is not in the model
                    foreach (var annotation in annotations)
                    {
                        var childFactoryContext = new PipelineStepFactoryContext
                        {
                            PipelineContext = factoryContext.PipelineContext,
                            Resource = deploymentTarget
                        };
 
                        var deploymentTargetSteps = await annotation.CreateStepsAsync(childFactoryContext).ConfigureAwait(false);
 
                        foreach (var step in deploymentTargetSteps)
                        {
                            // Ensure the step is associated with the deployment target resource
                            step.Resource ??= deploymentTarget;
                        }
 
                        steps.AddRange(deploymentTargetSteps);
                    }
                }
            }
 
            return steps;
        }));
 
        // Add pipeline configuration annotation to wire up dependencies
        // This is where we wire up the build steps created by the resources
        Annotations.Add(new PipelineConfigurationAnnotation(context =>
        {
            // Wire up build step dependencies
            // Build steps are created by ProjectResource and ContainerResource
            foreach (var computeResource in context.Model.GetComputeResources())
            {
                var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
 
                if (deploymentTarget is null)
                {
                    continue;
                }
 
                // Execute the PipelineConfigurationAnnotation callbacks on the deployment target
                if (deploymentTarget.TryGetAnnotationsOfType<PipelineConfigurationAnnotation>(out var annotations))
                {
                    foreach (var annotation in annotations)
                    {
                        annotation.Callback(context);
                    }
                }
            }
 
            // This ensures that resources that have to be built before deployments are handled
            foreach (var computeResource in context.Model.GetBuildResources())
            {
                context.GetSteps(computeResource, WellKnownPipelineTags.BuildCompute)
                        .RequiredBy(WellKnownPipelineSteps.Deploy)
                        .DependsOn(WellKnownPipelineSteps.DeployPrereq);
            }
 
            // Make print-summary step depend on provisioning of this environment
            var printSummarySteps = context.GetSteps(this, "print-summary");
            var provisionSteps = context.GetSteps(this, WellKnownPipelineTags.ProvisionInfrastructure);
            printSummarySteps.DependsOn(provisionSteps);
        }));
    }
 
    /// <summary>
    /// Materializes Azure App Service deployment targets for compute resources targeted to this
    /// environment. Invoked by the per-environment <c>prepare-azure-app-service-{name}</c>
    /// pipeline step, which runs after <c>azure-prepare-resources</c> so role-assignment resources
    /// are present in the model and can be referenced by the generated
    /// <see cref="DeploymentTargetAnnotation"/> instances.
    /// </summary>
    private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context)
    {
        var appModel = context.Model;
        var executionContext = context.ExecutionContext;
        var services = context.Services;
        var cancellationToken = context.CancellationToken;
 
        if (!executionContext.IsPublishMode)
        {
            return;
        }
 
        // Remove the default container registry from the model if an explicit registry is configured
        if (this.HasAnnotationOfType<ContainerRegistryReferenceAnnotation>() &&
            DefaultContainerRegistry is not null)
        {
            appModel.Resources.Remove(DefaultContainerRegistry);
            DefaultContainerRegistry = null;
        }
 
        var logger = services.GetRequiredService<ILogger<AzureAppServiceEnvironmentResource>>();
        var provisioningOptions = services.GetRequiredService<IOptions<AzureProvisioningOptions>>();
 
        var appServiceEnvironmentContext = new AzureAppServiceEnvironmentContext(
            logger,
            executionContext,
            this,
            services);
 
        // Annotate the environment with its context
        Annotations.Add(new AzureAppServiceEnvironmentContextAnnotation(appServiceEnvironmentContext));
 
        foreach (var resource in appModel.GetComputeResources())
        {
            // Support project resources and containers with Dockerfile
            if (resource is not ProjectResource && !(resource.IsContainer() && resource.TryGetAnnotationsOfType<DockerfileBuildAnnotation>(out _)))
            {
                continue;
            }
 
            // Skip resources that are explicitly targeted to a different compute environment
            var resourceComputeEnvironment = resource.GetComputeEnvironment();
            if (resourceComputeEnvironment is not null && resourceComputeEnvironment != this)
            {
                continue;
            }
 
            var website = await appServiceEnvironmentContext.CreateAppServiceAsync(resource, provisioningOptions.Value, cancellationToken).ConfigureAwait(false);
 
            resource.Annotations.Add(new DeploymentTargetAnnotation(website)
            {
                ContainerRegistry = this,
                ComputeEnvironment = this
            });
        }
 
        // Log once about all HTTP endpoints upgraded to HTTPS
        appServiceEnvironmentContext.LogHttpsUpgradeIfNeeded();
    }
 
    private async Task PrintDashboardUrlAsync(PipelineStepContext context)
    {
        var dashboardUri = await DashboardUriReference.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
 
        if (!string.IsNullOrEmpty(dashboardUri))
        {
            context.Summary.Add("📊 Dashboard", new MarkdownString($"[{dashboardUri}]({dashboardUri})"));
        }
 
        await context.ReportingStep.CompleteAsync(
            new MarkdownString($"Dashboard available at [{dashboardUri}]({dashboardUri})"),
            CompletionState.Completed,
            context.CancellationToken).ConfigureAwait(false);
    }
 
    private async Task ValidateAppServiceConfigurationAsync(PipelineStepContext context, DistributedApplicationModel model)
    {
        // Check for validation errors in all app service contexts
        if (!this.TryGetLastAnnotation<AzureAppServiceEnvironmentContextAnnotation>(out var contextAnnotation))
        {
            await context.ReportingStep.CompleteAsync(
                "App Service configuration validated",
                CompletionState.Completed,
                context.CancellationToken).ConfigureAwait(false);
            return;
        }
 
        var errors = new List<string>();
 
        foreach (var computeResource in model.GetComputeResources())
        {
            var deploymentTarget = computeResource.GetDeploymentTargetAnnotation(this)?.DeploymentTarget;
            if (deploymentTarget is null)
            {
                continue;
            }
 
            // Get the app service context for this resource and validate
            try
            {
                var appServiceContext = contextAnnotation.EnvironmentContext.GetAppServiceContext(computeResource);
                var validationError = ValidateEnvironmentVariableNames(computeResource, appServiceContext);
                if (validationError is not null)
                {
                    errors.Add(validationError);
                }
            }
            catch (InvalidOperationException)
            {
                // Context not found for this resource - skip
            }
        }
 
        if (errors.Count > 0)
        {
            // Report each error through the activity reporter for user-friendly display
            foreach (var error in errors)
            {
                context.ReportingStep.Log(LogLevel.Error, error);
            }
 
            await context.ReportingStep.CompleteAsync(
                "App Service configuration validation failed",
                CompletionState.CompletedWithError,
                context.CancellationToken).ConfigureAwait(false);
 
            // Throw to stop the pipeline - the error has already been reported through the activity reporter
            throw new DistributedApplicationException("App Service configuration validation failed. See errors above.");
        }
 
        await context.ReportingStep.CompleteAsync(
            "App Service configuration validated",
            CompletionState.Completed,
            context.CancellationToken).ConfigureAwait(false);
    }
 
    private static string? ValidateEnvironmentVariableNames(IResource resource, AzureAppServiceWebsiteContext appServiceContext)
    {
        if (resource.HasAnnotationOfType<AzureAppServiceIgnoreEnvironmentVariableChecksAnnotation>())
        {
            return null;
        }
 
        // Azure App Service removes '-' from environment variable names at runtime.
        // This breaks configuration binding for connection strings that use dashed names.
        var problematicKeys = appServiceContext.EnvironmentVariables.Keys
            .Where(static k => k.Contains('-', StringComparison.Ordinal))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(static k => k, StringComparer.OrdinalIgnoreCase)
            .ToArray();
 
        if (problematicKeys.Length == 0)
        {
            return null;
        }
 
        var sb = new StringBuilder();
        sb.AppendLine("Resource '" + resource.Name + "' cannot be published to Azure App Service because it has environment variables with '-' in the name.");
        sb.AppendLine();
        sb.AppendLine("Azure App Service removes '-' characters from environment variable names at runtime.");
        sb.AppendLine("This will change the keys and can prevent Aspire client integrations from finding the expected connection string.");
        sb.AppendLine();
        sb.AppendLine("Affected setting(s):");
 
        foreach (var key in problematicKeys)
        {
            var normalized = key.Replace("-", string.Empty, StringComparison.Ordinal);
            sb.AppendLine("  - " + key + " (becomes " + normalized + ")");
        }
 
        sb.AppendLine();
        sb.AppendLine("Fix options:");
        sb.AppendLine("  - Use a dash-free connection name in the AppHost (e.g. WithReference(resource, connectionName: \"mydb\")).");
        sb.AppendLine($"  - Call {nameof(AzureAppServiceComputeResourceExtensions.SkipEnvironmentVariableNameChecks)}() on this resource to bypass this validation.");
 
        return sb.ToString();
    }
 
    // We don't want these to be public if we end up with an app service
    // per compute resource.
    internal BicepOutputReference PlanIdOutputReference => new("planId", this);
    internal BicepOutputReference ContainerRegistryUrl => new("AZURE_CONTAINER_REGISTRY_ENDPOINT", this);
    internal BicepOutputReference ContainerRegistryName => new("AZURE_CONTAINER_REGISTRY_NAME", this);
    internal BicepOutputReference ContainerRegistryManagedIdentityId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID", this);
    internal BicepOutputReference ContainerRegistryClientId => new("AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID", this);
    internal BicepOutputReference WebsiteContributorManagedIdentityId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID", this);
    internal BicepOutputReference WebsiteContributorManagedIdentityPrincipalId => new("AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID", this);
 
    /// <summary>
    /// Gets the suffix added to each web app created in this App Service Environment.
    /// </summary>
    internal BicepOutputReference WebSiteSuffix => new("webSiteSuffix", this);
 
    /// <summary>
    /// Gets the delegated subnet ID configured for this environment, if any.
    /// </summary>
#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
    internal BicepValue<ResourceIdentifier>? GetDelegatedSubnetId(AzureResourceInfrastructure infra)
    {
        if (this.TryGetLastAnnotation<DelegatedSubnetAnnotation>(out var subnetAnnotation))
        {
            return subnetAnnotation.SubnetId.AsProvisioningParameter(infra);
        }
 
        return null;
    }
#pragma warning restore ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
    /// <summary>
    /// When true, HTTP endpoints are not upgraded to HTTPS. Default is false (HTTP→HTTPS upgrade is enabled).
    /// </summary>
    internal bool PreserveHttpEndpoints { get; set; }
 
    /// <summary>
    /// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment.
    /// Default is true.
    /// </summary>
    internal bool EnableDashboard { get; set; } = true;
 
    /// <summary>
    /// Gets or sets a value indicating whether Application Insights telemetry should be enabled in the app service environment.
    /// </summary>
    internal bool EnableApplicationInsights { get; set; }
 
    /// <summary>
    /// Gets the location for the Application Insights resource. If <c>null</c>, the resource group location is used.
    /// </summary>
    internal string? ApplicationInsightsLocation { get; set; }
 
    /// <summary>
    /// Parameter resource for the Application Insights location.
    /// </summary>
    internal ParameterResource? ApplicationInsightsLocationParameter { get; set; }
 
    /// <summary>
    /// Application Insights resource.
    /// </summary>
    internal AzureApplicationInsightsResource? ApplicationInsightsResource { get; set; }
 
    /// <summary>
    /// Deployment slot parameter resource for the App Service Environment.
    /// </summary>
    internal ParameterResource? DeploymentSlotParameter { get; set; }
 
    /// <summary>
    /// Deployment slot for the App Service Environment.
    /// </summary>
    internal string? DeploymentSlot { get; set; }
 
    /// <summary>
    /// Gets the name of the App Service Plan.
    /// </summary>
    public BicepOutputReference NameOutputReference => new("name", this);
 
    /// <summary>
    /// Gets the URI of the App Service Environment dashboard.
    /// </summary>
    public BicepOutputReference DashboardUriReference => new("AZURE_APP_SERVICE_DASHBOARD_URI", this);
 
    /// <summary>
    /// Gets the Application Insights Instrumentation Key.
    /// </summary>
    public BicepOutputReference AzureAppInsightsInstrumentationKeyReference =>
        new("AZURE_APPLICATION_INSIGHTS_INSTRUMENTATIONKEY", this);
 
    /// <summary>
    /// Gets the Application Insights Connection String.
    /// </summary>
    public BicepOutputReference AzureAppInsightsConnectionStringReference =>
        new("AZURE_APPLICATION_INSIGHTS_CONNECTION_STRING", this);
 
    internal static BicepValue<string> GetWebSiteSuffixBicep() =>
        BicepFunction.GetUniqueString(BicepFunction.GetResourceGroup().Id);
 
    /// <summary>
    /// Gets the default container registry for this environment.
    /// </summary>
    internal AzureContainerRegistryResource? DefaultContainerRegistry { get; set; }
 
    ReferenceExpression IContainerRegistry.Name => GetContainerRegistry()?.Name ?? ReferenceExpression.Create($"{ContainerRegistryName}");
 
    ReferenceExpression IContainerRegistry.Endpoint => GetContainerRegistry()?.Endpoint ?? ReferenceExpression.Create($"{ContainerRegistryUrl}");
 
    IAzureContainerRegistryResource? IAzureComputeEnvironmentResource.ContainerRegistry => ContainerRegistry;
 
    private IContainerRegistry? GetContainerRegistry()
    {
        // Check for explicit container registry reference annotation
        if (this.TryGetLastAnnotation<ContainerRegistryReferenceAnnotation>(out var annotation))
        {
            return annotation.Registry;
        }
 
        // Fall back to default container registry
        return DefaultContainerRegistry;
    }
 
    /// <summary>
    /// Gets the Azure Container Registry resource used by this Azure App Service Environment resource.
    /// </summary>
    public AzureContainerRegistryResource? ContainerRegistry
    {
        get
        {
            var registry = GetContainerRegistry();
 
            if (registry is null)
            {
                return null;
            }
 
            if (registry is not AzureContainerRegistryResource azureRegistry)
            {
                throw new InvalidOperationException(
                    $"The container registry configured for the Azure App Service Environment '{Name}' is not an Azure Container Registry. " +
                    $"Only Azure Container Registry resources are supported. Use '.WithAzureContainerRegistry()' to configure an Azure Container Registry.");
            }
 
            return azureRegistry;
        }
    }
 
#pragma warning disable CS0618 // Type or member is obsolete
    ReferenceExpression IAzureContainerRegistry.ManagedIdentityId => ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}");
#pragma warning restore CS0618 // Type or member is obsolete
 
    /// <inheritdoc/>
    [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference)
    {
        var resource = endpointReference.Resource;
        return ReferenceExpression.Create($"{resource.Name.ToLowerInvariant()}-{WebSiteSuffix}.azurewebsites.net");
    }
 
    /// <inheritdoc/>
    [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public ReferenceExpression GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression)
    {
        ArgumentNullException.ThrowIfNull(endpointReferenceExpression);
 
        var endpointReference = endpointReferenceExpression.Endpoint;
        var property = endpointReferenceExpression.Property;
        var endpoint = endpointReference.EndpointAnnotation;
        var scheme = PreserveHttpEndpoints ? endpoint.UriScheme : "https";
        var port = string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) ? 80 : 443;
        var tlsEnabled = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) || endpoint.TlsEnabled;
        var host = GetHostAddressExpression(endpointReference);
 
        return property switch
        {
            EndpointProperty.Url => ReferenceExpression.Create($"{scheme}://{host}"),
            EndpointProperty.Host or EndpointProperty.IPV4Host => host,
            EndpointProperty.Port => ReferenceExpression.Create($"{port.ToString(CultureInfo.InvariantCulture)}"),
            EndpointProperty.TargetPort => endpoint.TargetPort is int targetPort
                ? ReferenceExpression.Create($"{targetPort.ToString(CultureInfo.InvariantCulture)}")
                : ReferenceExpression.Create($"{new ContainerPortReference(endpointReference.Resource)}"),
            EndpointProperty.Scheme => ReferenceExpression.Create($"{scheme}"),
            EndpointProperty.HostAndPort => host,
            EndpointProperty.TlsEnabled => ReferenceExpression.Create($"{(tlsEnabled ? bool.TrueString : bool.FalseString)}"),
            _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{endpoint.Name}'.")
        };
    }
 
    /// <inheritdoc/>
    public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra)
    {
        var bicepIdentifier = this.GetBicepIdentifier();
        var resources = infra.GetProvisionableResources();
 
        // Check if an AppServicePlan with the same identifier already exists
        var existingPlan = resources.OfType<AppServicePlan>().SingleOrDefault(plan => plan.BicepIdentifier == bicepIdentifier);
 
        if (existingPlan is not null)
        {
            return existingPlan;
        }
 
        // Create and add new resource if it doesn't exist
        var plan = AppServicePlan.FromExisting(bicepIdentifier);
 
        if (!TryApplyExistingResourceAnnotation(
            this,
            infra,
            plan))
        {
            plan.Name = NameOutputReference.AsProvisioningParameter(infra);
        }
 
        infra.Add(plan);
        return plan;
    }
}