Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,20 @@ internal static BicepValue<string> GetWebSiteSuffixBicep() =>
ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference)
{
var resource = endpointReference.Resource;
return ReferenceExpression.Create($"{resource.Name.ToLowerInvariant()}-{WebSiteSuffix}.azurewebsites.net");

// Try to find a DNL annotation with a resolved hostname
var dnlAnnotation = resource.Annotations
.OfType<DynamicNetworkLocationAnnotation>()
.FirstOrDefault();

if (dnlAnnotation is not null && !string.IsNullOrEmpty(dnlAnnotation.HostName))
{
// Use the dynamically discovered hostname
return ReferenceExpression.Create($"{dnlAnnotation.HostName}");
}

// Fallback to deterministic naming
return ReferenceExpression.Create($"wxyz-{resource.Name.ToLowerInvariant()}-{WebSiteSuffix}.azurewebsites.net");
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Azure.Provisioning;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -42,6 +43,91 @@ public AzureAppServiceWebSiteResource(string name, Action<AzureResourceInfrastru

var steps = new List<PipelineStep>();

var fetchHostNameStep = new PipelineStep
{
Name = $"fetch-hostname-{TargetResource.Name}",
Action = async ctx =>
{
var computerEnv = (AzureAppServiceEnvironmentResource)deploymentTargetAnnotation.ComputeEnvironment!;
var websiteSuffix = await computerEnv.WebSiteSuffix.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false);

var (hostName, isAvailable) = await AzureEnvironmentResourceHelpers.GetDnlHostNameAsync(TargetResource, websiteSuffix, ctx).ConfigureAwait(false);

if (!string.IsNullOrEmpty(hostName))
{
var hostNameAnnotation = TargetResource.Annotations
.OfType<HostNameParameterAnnotation>()
.FirstOrDefault();

hostNameAnnotation?.Parameter.Value = new BicepValue<string>(hostName);

ctx.ReportingStep.Log(LogLevel.Information, $"Fetched App Service hostname: {hostName}", true);
if (hostNameAnnotation is not null)
{
ctx.ReportingStep.Log(LogLevel.Information, $"Updated HostNameParameterAnnotation for {TargetResource.Name} with hostname: {hostName}", true);
}
else
{
ctx.ReportingStep.Log(LogLevel.Warning, $"HostNameParameterAnnotation not found on {TargetResource.Name}, could not update with hostname: {hostName}", true);
}
}
else
{
ctx.ReportingStep.Log(LogLevel.Warning, $"Could not fetch App Service hostname for {hostName}", true);
}
},
Tags = ["fetch-hostname"]
};

steps.Add(fetchHostNameStep);

/*
var updateEndpointReferencesStep = new PipelineStep
{
Name = $"update-endpoint-references-{TargetResource.Name}",
Action = ctx =>
{
// Find the DNL annotation on the target resource
var dnlAnnotation = TargetResource.Annotations
.OfType<DynamicNetworkLocationAnnotation>()
.FirstOrDefault();

if (dnlAnnotation is null || string.IsNullOrEmpty(dnlAnnotation.HostName))
{
ctx.ReportingStep.Log(LogLevel.Warning, $"No DNL annotation found for {TargetResource.Name}, skipping endpoint update.", true);
return Task.CompletedTask;
}

// Update EndpointReference for all compute resources in the model
foreach (var resource in ctx.Model.GetComputeResources())
{
if (resource.TryGetEndpoints(out var endpoints))
{
foreach (var endpoint in endpoints)
{
// Update the endpoint's reference to use the DNL hostname
// This assumes EndpointReference has a property or method to set the host.
// If not, you may need to update the endpoint's Uri or a custom annotation.
if (endpoint is EndpointReference endpointRef)
{
endpointRef.Host = dnlAnnotation.HostName;
}
// If endpoints are not EndpointReference, but have a Host/Uri property, update accordingly:
// endpoint.Host = dnlAnnotation.HostName;
}
}
}

ctx.ReportingStep.Log(LogLevel.Information, $"Updated EndpointReference for all compute resources to use host: {dnlAnnotation.HostName}", true);
return Task.CompletedTask;
},
Tags = ["update-endpoint-references"]
};

// Ensure this step runs after fetchHostNameStep
updateEndpointReferencesStep.DependsOn(fetchHostNameStep);
steps.Add(updateEndpointReferencesStep);*/

if (targetResource.RequiresImageBuildAndPush())
{
// Create push step for this deployment target
Expand Down Expand Up @@ -126,8 +212,12 @@ await AzureEnvironmentResourceHelpers.PushImageToRegistryAsync(
pushSteps.DependsOn(registryProvisionSteps);
}

// Ensure fetch-hostname step is required by provision infrastructure
var fetchHostNameSteps = context.GetSteps(this, "fetch-hostname");

// The app deployment should depend on the push step
provisionSteps.DependsOn(pushSteps);
provisionSteps.DependsOn(fetchHostNameSteps);

// Ensure summary step runs after provision
context.GetSteps(this, "print-summary").DependsOn(provisionSteps);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed class AzureAppServiceWebsiteContext(
{
public IResource Resource => resource;

record struct EndpointMapping(string Scheme, BicepValue<string> Host, int Port, int? TargetPort, bool IsHttpIngress, bool External);
record struct EndpointMapping(string Scheme, BicepValue<string> WebSiteName, BicepValue<string> Host, int Port, int? TargetPort, bool IsHttpIngress, bool External);

private readonly Dictionary<string, EndpointMapping> _endpointMapping = [];

Expand All @@ -34,8 +34,38 @@ record struct EndpointMapping(string Scheme, BicepValue<string> Host, int Port,

// Naming the app service is globally unique (domain names), so we use the resource group ID to create a unique name
// within the naming spec for the app service.
public BicepValue<string> HostName => BicepFunction.Take(
BicepFunction.Interpolate($"{BicepFunction.ToLower(resource.Name)}-{AzureAppServiceEnvironmentResource.GetWebSiteSuffixBicep()}"), 60);
public BicepValue<string> WebSiteName =>
BicepFunction.Take(
BicepFunction.Interpolate($"{BicepFunction.ToLower(Resource.Name)}-{AzureAppServiceEnvironmentResource.GetWebSiteSuffixBicep()}"), 60);

// Parameter to hold the hostname value
private readonly ParameterResource _hostNameParameter = new ParameterResource("hostname-" + resource.Name.ToLowerInvariant(), _ => "");

private ProvisioningParameter _hostNameProvisioningParameter =>
AllocateParameter(_hostNameParameter, secretType: SecretType.None);

// Naming the app service is globally unique (domain names), so we use the resource group ID to create a unique name
// within the naming spec for the app service.
public BicepValue<string> HostName
{
get
{
// Check for DNL annotation
var dnl = Resource.Annotations
.OfType<DynamicNetworkLocationAnnotation>()
.FirstOrDefault();

if (dnl is not null && !string.IsNullOrWhiteSpace(dnl.HostName))
{
// Use the dynamic hostname directly
return dnl.HostName;
}

// Fallback to deterministic logic
return BicepFunction.Take(
BicepFunction.Interpolate($"{BicepFunction.ToLower("abcd" + Resource.Name)}-{AzureAppServiceEnvironmentResource.GetWebSiteSuffixBicep()}"), 60);
}
}

public async Task ProcessAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -107,6 +137,8 @@ private void ProcessEndpoints()
_ => null
};

resource.Annotations.Add(new HostNameParameterAnnotation(_hostNameProvisioningParameter));

foreach (var endpoint in endpoints)
{
if (!endpoint.IsExternal)
Expand All @@ -117,7 +149,8 @@ private void ProcessEndpoints()
// For App Service, we ignore port mappings since ports are handled by the platform
_endpointMapping[endpoint.Name] = new(
Scheme: endpoint.UriScheme,
Host: HostName,
WebSiteName: WebSiteName,
Host: _hostNameProvisioningParameter,
Port: endpoint.UriScheme == "https" ? 443 : 80,
TargetPort: endpoint.TargetPort ?? fallbackTargetPort,
IsHttpIngress: true,
Expand Down Expand Up @@ -248,7 +281,7 @@ public void BuildWebSite(AzureResourceInfrastructure infra)
var webSite = new WebSite("webapp")
{
// Use the host name as the name of the web app
Name = HostName,
Name = WebSiteName,
AppServicePlanId = appServicePlanParameter,
// Creating the app service with new sidecar configuration
SiteConfig = new SiteConfigProperties()
Expand Down Expand Up @@ -411,13 +444,13 @@ private BicepValue<string> GetEndpointValue(EndpointMapping mapping, EndpointPro
{
return property switch
{
EndpointProperty.Url => BicepFunction.Interpolate($"{mapping.Scheme}://{mapping.Host}.azurewebsites.net"),
EndpointProperty.Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
EndpointProperty.Url => BicepFunction.Interpolate($"{mapping.Scheme}://{mapping.Host}"),
EndpointProperty.Host => BicepFunction.Interpolate($"{mapping.Host}"),
EndpointProperty.Port => mapping.Port.ToString(CultureInfo.InvariantCulture),
EndpointProperty.TargetPort => mapping.TargetPort?.ToString(CultureInfo.InvariantCulture) ?? (BicepValue<string>)AllocateParameter(new ContainerPortReference(Resource)),
EndpointProperty.Scheme => mapping.Scheme,
EndpointProperty.HostAndPort => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
EndpointProperty.IPV4Host => BicepFunction.Interpolate($"{mapping.Host}.azurewebsites.net"),
EndpointProperty.HostAndPort => BicepFunction.Interpolate($"{mapping.Host}"),
EndpointProperty.IPV4Host => BicepFunction.Interpolate($"{mapping.Host}"),
_ => throw new NotSupportedException($"Unsupported endpoint property {property}")
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;
using Azure.Provisioning;
using Azure.Provisioning.AppService;

namespace Aspire.Hosting.Azure;
Expand All @@ -17,3 +18,40 @@ public sealed class AzureAppServiceWebsiteCustomizationAnnotation(Action<AzureRe
/// </summary>
public Action<AzureResourceInfrastructure, WebSite> Configure { get; } = configure ?? throw new ArgumentNullException(nameof(configure));
}

/// <summary>
/// Represents an annotation that dynamically specifies a network location by its host name.
/// </summary>
/// <remarks>This annotation is typically used to associate a resource with a network location that is identified
/// by a host name.</remarks>
public class DynamicNetworkLocationAnnotation : IResourceAnnotation
{
/// <summary>
/// Host name of the dynamic network location.
/// </summary>
public string HostName { get; }

/// <summary>
/// Initializes a new instance of the <see cref="DynamicNetworkLocationAnnotation"/> class with the specified host
/// name.
/// </summary>
/// <remarks>The <paramref name="hostName"/> parameter is used to identify the network location
/// dynamically. Ensure that the provided host name is valid and properly formatted.</remarks>
/// <param name="hostName">The host name associated with the network location. Cannot be null or empty.</param>
public DynamicNetworkLocationAnnotation(string hostName)
{
HostName = hostName;
}
}

/// <summary>
/// Host name parameter annotation.
/// </summary>
/// <param name="parameter"></param>
public sealed class HostNameParameterAnnotation(ProvisioningParameter parameter) : IResourceAnnotation
{
/// <summary>
/// Host name parameter resource.
/// </summary>
public ProvisioningParameter Parameter { get; internal set; } = parameter;
}
67 changes: 67 additions & 0 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResourceHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Aspire.Hosting.Azure.Provisioning.Internal;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Publishing;
using Azure.Core;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.Azure;
Expand Down Expand Up @@ -97,6 +98,72 @@ public static async Task PushImageToRegistryAsync(IContainerRegistry registry, I
}
}

public static async Task<(string? HostName, bool IsAvailable)> GetDnlHostNameAsync(IResource resource, string? websiteSuffix, PipelineStepContext context)
{
// Get required services
var httpClientFactory = context.Services.GetService<IHttpClientFactory>();

if (httpClientFactory is null)
{
throw new InvalidOperationException("IHttpClientFactory is not registered in the service provider.");
}

var tokenCredentialProvider = context.Services.GetRequiredService<ITokenCredentialProvider>();

// Find the AzureEnvironmentResource from the application model
var azureEnvironment = context.Model.Resources.OfType<AzureEnvironmentResource>().FirstOrDefault();
if (azureEnvironment == null)
{
throw new InvalidOperationException("AzureEnvironmentResource must be present in the application model.");
}

var provisioningContext = await azureEnvironment.ProvisioningContextTask.Task.ConfigureAwait(false);
var subscriptionId = provisioningContext.Subscription.Id.SubscriptionId?.ToString()
?? throw new InvalidOperationException("SubscriptionId is required.");
var location = provisioningContext.Location.Name
?? throw new InvalidOperationException("Location is required.");

// Prepare ARM endpoint and request
var armEndpoint = "https://management.azure.com";
var apiVersion = "2025-03-01";
var siteName = $"{resource.Name.ToLowerInvariant()}-{websiteSuffix?.ToLowerInvariant()}";
if (siteName.Length > 60)
{
siteName = siteName.Substring(0, 60);
}
var url = $"{armEndpoint}/subscriptions/{subscriptionId}/providers/Microsoft.Web/locations/{location}/CheckNameAvailability?api-version={apiVersion}";
var requestBody = new
{
name = siteName,
type = "Microsoft.Web/sites"
};

var tokenRequest = new TokenRequestContext(["https://management.azure.com/.default"]);
// Get access token for ARM
var token = await tokenCredentialProvider.TokenCredential
.GetTokenAsync(tokenRequest, context.CancellationToken)
.ConfigureAwait(false);

var httpClient = httpClientFactory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(requestBody), System.Text.Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);

using var response = await httpClient.SendAsync(request, context.CancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

using var responseStream = await response.Content.ReadAsStreamAsync(context.CancellationToken).ConfigureAwait(false);
using var doc = await System.Text.Json.JsonDocument.ParseAsync(responseStream, cancellationToken: context.CancellationToken).ConfigureAwait(false);

var root = doc.RootElement;
var isAvailable = root.GetProperty("nameAvailable").GetBoolean();
var hostName = root.GetProperty("hostName").GetString();

return (hostName, isAvailable);
}

private static async Task TagAndPushImage(string localTag, string targetTag, CancellationToken cancellationToken, IResourceContainerImageBuilder containerImageBuilder)
{
await containerImageBuilder.TagImageAsync(localTag, targetTag, cancellationToken).ConfigureAwait(false);
Expand Down
Loading