Skip to content
Closed
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 @@ -3,10 +3,12 @@

#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREAZURE001
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES003

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Azure.AppContainers;
Expand Down Expand Up @@ -61,6 +63,13 @@ public AzureContainerAppResource(string name, Action<AzureResourceInfrastructure
ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{targetResource.Name}** to Azure Container Apps environment **{containerAppEnv.Name}**. No public endpoints were configured."));
ctx.Summary.Add(targetResource.Name, "No public endpoints");
}

// Add Azure Portal link for the deployed resource
var portalUrl = await GetPortalUrlAsync(ctx, targetResource.Name, cancellationToken: ctx.CancellationToken).ConfigureAwait(false);
if (portalUrl is not null)
{
ctx.Summary.Add($"🔗 {targetResource.Name}", new MarkdownString($"[Azure Portal]({portalUrl})"));
}
},
Tags = ["print-summary"],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
Expand Down Expand Up @@ -100,4 +109,29 @@ public AzureContainerAppResource(string name, Action<AzureResourceInfrastructure
/// Gets the target resource that this Azure Container App is being created for.
/// </summary>
public IResource TargetResource { get; }

private static async Task<string?> GetPortalUrlAsync(PipelineStepContext context, string resourceName, CancellationToken cancellationToken)
{
var deploymentStateManager = context.Services.GetService<IDeploymentStateManager>();
if (deploymentStateManager is null)
{
return null;
}

var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);

var subscriptionId = azureStateSection.Data["SubscriptionId"]?.ToString();
var resourceGroupName = azureStateSection.Data["ResourceGroup"]?.ToString();
var tenantId = azureStateSection.Data["TenantId"]?.ToString();

if (subscriptionId is null || resourceGroupName is null)
{
return null;
}

var resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.App/containerApps/{resourceName}";
Guid? tenantGuid = Guid.TryParse(tenantId, out var parsed) ? parsed : null;

return AzurePortalUrls.GetResourceUrl(resourceId, tenantGuid);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREAZURE001
#pragma warning disable ASPIREPIPELINES002
#pragma warning disable ASPIREPIPELINES003

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting.Azure;
Expand Down Expand Up @@ -59,6 +61,16 @@ public AzureAppServiceWebSiteResource(string name, Action<AzureResourceInfrastru
var endpoint = $"https://{hostName}.azurewebsites.net";
ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{targetResource.Name}** to [{endpoint}]({endpoint})"));
ctx.Summary.Add(targetResource.Name, new MarkdownString($"[{endpoint}]({endpoint})"));

// Use the base App Service site resource name for Azure Portal links / ARM resource IDs.
var websiteName = await GetAppServiceWebsiteNameAsync(ctx, null).ConfigureAwait(false);

// Add Azure Portal link for the deployed resource
var portalUrl = await GetPortalUrlAsync(ctx, websiteName, deploymentSlot, ctx.CancellationToken).ConfigureAwait(false);
if (portalUrl is not null)
{
ctx.Summary.Add($"🔗 {targetResource.Name}", new MarkdownString($"[Azure Portal]({portalUrl})"));
}
},
Tags = ["print-summary"],
RequiredBySteps = [WellKnownPipelineSteps.Deploy]
Expand Down Expand Up @@ -135,4 +147,32 @@ private static string TruncateToMaxLength(string value, int maxLength)
// Source of truth: https://msazure.visualstudio.com/One/_git/AAPT-Antares-Websites?path=%2Fsrc%2FHosting%2FAdministrationService%2FMicrosoft.Web.Hosting.Administration.Api%2FCommonConstants.cs&_a=contents&version=GBdev
internal const int MaxHostPrefixLengthWithSlot = 59;
internal const int MaxWebSiteNamePrefixLengthWithSlot = 40;

private static async Task<string?> GetPortalUrlAsync(PipelineStepContext context, string websiteName, string? deploymentSlot, CancellationToken cancellationToken)
{
var deploymentStateManager = context.Services.GetService<IDeploymentStateManager>();
if (deploymentStateManager is null)
{
return null;
}

var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false);

var subscriptionId = azureStateSection.Data["SubscriptionId"]?.ToString();
var resourceGroupName = azureStateSection.Data["ResourceGroup"]?.ToString();
var tenantId = azureStateSection.Data["TenantId"]?.ToString();

if (subscriptionId is null || resourceGroupName is null)
{
return null;
}

var resourceId = string.IsNullOrWhiteSpace(deploymentSlot)
? $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{websiteName}"
: $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Web/sites/{websiteName}/slots/{deploymentSlot}";

Guid? tenantGuid = Guid.TryParse(tenantId, out var parsed) ? parsed : null;

return AzurePortalUrls.GetResourceUrl(resourceId, tenantGuid);
}
}
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<InternalsVisibleTo Include="Aspire.Hosting.Azure.Tests" />
<InternalsVisibleTo Include="Aspire.Hosting.Azure.ContainerRegistry" />
<InternalsVisibleTo Include="Aspire.Hosting.Azure.AppService" />
<InternalsVisibleTo Include="Aspire.Hosting.Azure.AppContainers" />
</ItemGroup>

</Project>
19 changes: 17 additions & 2 deletions src/Aspire.Hosting.Azure/AzurePortalUrls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,30 @@ namespace Aspire.Hosting.Azure;
/// </summary>
internal static class AzurePortalUrls
{
private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id";
private const string PortalBaseUrl = "https://portal.azure.com/";
private const string PortalDeploymentOverviewUrl = PortalBaseUrl + "#view/HubsExtension/DeploymentDetailsBlade/~/overview/id";

/// <summary>
/// Gets the Azure portal URL for a resource group overview page.
/// </summary>
internal static string GetResourceGroupUrl(string subscriptionId, string resourceGroupName, Guid? tenantId = null)
{
var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#";
return $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview";
return $"{PortalBaseUrl}{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview";
}

/// <summary>
/// Gets the Azure portal URL for a specific Azure resource using its fully qualified resource ID.
/// </summary>
/// <remarks>
/// The resource ID should be in the standard ARM format, e.g.:
/// <c>/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name}</c>
/// This also handles child resources like App Service deployment slots.
/// </remarks>
internal static string GetResourceUrl(string resourceId, Guid? tenantId = null)
{
var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#";
return $"{PortalBaseUrl}{tenantSegment}/resource{resourceId}";
}

/// <summary>
Expand Down
80 changes: 80 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzurePortalUrlsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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.Azure.Tests;

public class AzurePortalUrlsTests
{
[Fact]
public void GetResourceGroupUrl_WithoutTenantId_ReturnsCorrectUrl()
{
var url = AzurePortalUrls.GetResourceGroupUrl("sub-123", "rg-myapp");

Assert.Equal("https://portal.azure.com/#/resource/subscriptions/sub-123/resourceGroups/rg-myapp/overview", url);
}

[Fact]
public void GetResourceGroupUrl_WithTenantId_IncludesTenantInUrl()
{
var tenantId = Guid.Parse("aaaabbbb-cccc-dddd-eeee-ffff00001111");
var url = AzurePortalUrls.GetResourceGroupUrl("sub-123", "rg-myapp", tenantId);

Assert.Equal("https://portal.azure.com/#@aaaabbbb-cccc-dddd-eeee-ffff00001111/resource/subscriptions/sub-123/resourceGroups/rg-myapp/overview", url);
}

[Fact]
public void GetResourceUrl_WithoutTenantId_ReturnsCorrectUrl()
{
var resourceId = "/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.App/containerApps/myapi";
var url = AzurePortalUrls.GetResourceUrl(resourceId);

Assert.Equal("https://portal.azure.com/#/resource/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.App/containerApps/myapi", url);
}

[Fact]
public void GetResourceUrl_WithTenantId_IncludesTenantInUrl()
{
var tenantId = Guid.Parse("aaaabbbb-cccc-dddd-eeee-ffff00001111");
var resourceId = "/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.App/containerApps/myapi";
var url = AzurePortalUrls.GetResourceUrl(resourceId, tenantId);

Assert.Equal("https://portal.azure.com/#@aaaabbbb-cccc-dddd-eeee-ffff00001111/resource/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.App/containerApps/myapi", url);
}

[Fact]
public void GetResourceUrl_WithAppServiceSite_ReturnsCorrectUrl()
{
var resourceId = "/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Web/sites/mywebapp";
var url = AzurePortalUrls.GetResourceUrl(resourceId);

Assert.Equal("https://portal.azure.com/#/resource/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Web/sites/mywebapp", url);
}

[Fact]
public void GetResourceUrl_WithAppServiceSlot_ReturnsCorrectUrl()
{
var resourceId = "/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Web/sites/mywebapp/slots/staging";
var url = AzurePortalUrls.GetResourceUrl(resourceId);

Assert.Equal("https://portal.azure.com/#/resource/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Web/sites/mywebapp/slots/staging", url);
}
Comment thread
spboyer marked this conversation as resolved.

[Fact]
public void GetDeploymentUrl_WithComponents_ReturnsUrlEncodedPath()
{
var url = AzurePortalUrls.GetDeploymentUrl("/subscriptions/sub-123", "rg-myapp", "deploy-001");

Assert.StartsWith("https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/", url);
Assert.Contains(Uri.EscapeDataString("/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Resources/deployments/deploy-001"), url);
}

[Fact]
public void GetDeploymentUrl_WithResourceIdentifier_ReturnsUrlEncodedPath()
{
var deploymentId = new global::Azure.Core.ResourceIdentifier(
"/subscriptions/sub-123/resourceGroups/rg-myapp/providers/Microsoft.Resources/deployments/deploy-001");
var url = AzurePortalUrls.GetDeploymentUrl(deploymentId);

Assert.StartsWith("https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/", url);
}
}
Loading