diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs index 97de4cb0ee9..4920d316965 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs @@ -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; @@ -61,6 +63,13 @@ public AzureContainerAppResource(string name, Action public IResource TargetResource { get; } + + private static async Task GetPortalUrlAsync(PipelineStepContext context, string resourceName, CancellationToken cancellationToken) + { + var deploymentStateManager = context.Services.GetService(); + 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); + } } diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs index 022ed9cc912..de5608e8b0d 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs @@ -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; @@ -59,6 +61,16 @@ public AzureAppServiceWebSiteResource(string name, Action GetPortalUrlAsync(PipelineStepContext context, string websiteName, string? deploymentSlot, CancellationToken cancellationToken) + { + var deploymentStateManager = context.Services.GetService(); + 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); + } } diff --git a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj index bc11518e795..7d3a4f6411a 100644 --- a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj +++ b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Aspire.Hosting.Azure/AzurePortalUrls.cs b/src/Aspire.Hosting.Azure/AzurePortalUrls.cs index 28c95acb5b0..3870a278436 100644 --- a/src/Aspire.Hosting.Azure/AzurePortalUrls.cs +++ b/src/Aspire.Hosting.Azure/AzurePortalUrls.cs @@ -8,7 +8,8 @@ namespace Aspire.Hosting.Azure; /// 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"; /// /// Gets the Azure portal URL for a resource group overview page. @@ -16,7 +17,21 @@ internal static class AzurePortalUrls 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"; + } + + /// + /// Gets the Azure portal URL for a specific Azure resource using its fully qualified resource ID. + /// + /// + /// The resource ID should be in the standard ARM format, e.g.: + /// /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.App/containerApps/{name} + /// This also handles child resources like App Service deployment slots. + /// + internal static string GetResourceUrl(string resourceId, Guid? tenantId = null) + { + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + return $"{PortalBaseUrl}{tenantSegment}/resource{resourceId}"; } /// diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePortalUrlsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePortalUrlsTests.cs new file mode 100644 index 00000000000..395cff81e1d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePortalUrlsTests.cs @@ -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); + } + + [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); + } +}