From e8f9014efc7b21972cf0b44ab352be9e71d89823 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 14 Apr 2026 23:03:58 -0700 Subject: [PATCH] Add Azure portal links to deploy summaries Surface Azure Portal links for resource groups, deployments, Container Apps, and App Service resources in Azure deploy summaries. Keep portal URL generation in shared helpers and update Azure deployer tests to use GUID-based ARM resource IDs so portal link parsing can continue using ResourceIdentifier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.Azure.AppContainers.csproj | 1 + .../AzureContainerAppResource.cs | 12 +- .../ContainerAppUrls.cs | 33 +++ .../AppSvcUrls.cs | 39 +++ .../Aspire.Hosting.Azure.AppService.csproj | 1 + .../AzureAppServiceWebSiteResource.cs | 28 ++- .../Aspire.Hosting.Azure.csproj | 2 +- .../AzureEnvironmentResource.cs | 5 +- src/Aspire.Hosting.Azure/AzurePortalUrls.cs | 39 --- src/Shared/AzurePortalUrls.cs | 119 +++++++++ .../AzureDeployerTests.cs | 228 ++++++++++++++---- 11 files changed, 407 insertions(+), 100 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.AppContainers/ContainerAppUrls.cs create mode 100644 src/Aspire.Hosting.Azure.AppService/AppSvcUrls.cs delete mode 100644 src/Aspire.Hosting.Azure/AzurePortalUrls.cs create mode 100644 src/Shared/AzurePortalUrls.cs diff --git a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj index 2aeb93f852a..6ff4d136e47 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj +++ b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs index 97de4cb0ee9..df349430b23 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs @@ -48,18 +48,22 @@ public AzureContainerAppResource(string name, Action e.IsExternal)) { var endpoint = $"https://{targetResource.Name.ToLowerInvariant()}.{domainValue}"; + var summaryValue = $"[{endpoint}]({endpoint}) ({portalLink})"; - ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{targetResource.Name}** to [{endpoint}]({endpoint})")); - ctx.Summary.Add(targetResource.Name, new MarkdownString($"[{endpoint}]({endpoint})")); + ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{targetResource.Name}** to {summaryValue}")); + ctx.Summary.Add(targetResource.Name, new MarkdownString(summaryValue)); } else { - 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"); + var summaryValue = $"No public endpoints ({portalLink})"; + + ctx.ReportingStep.Log(LogLevel.Information, new MarkdownString($"Successfully deployed **{targetResource.Name}** to Azure Container Apps environment **{containerAppEnv.Name}**. {summaryValue}")); + ctx.Summary.Add(targetResource.Name, new MarkdownString(summaryValue)); } }, Tags = ["print-summary"], diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppUrls.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppUrls.cs new file mode 100644 index 00000000000..1184755dbfe --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppUrls.cs @@ -0,0 +1,33 @@ +// 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 + +using Azure.Core; +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Azure.AppContainers; + +internal static class ContainerAppUrls +{ + private const string ContainerAppResourceType = "/providers/Microsoft.App/containerApps/"; + + internal static async Task GetPortalLinkAsync(AzureContainerAppEnvironmentResource containerAppEnv, string containerAppName, CancellationToken cancellationToken) + { + var environmentIdValue = await containerAppEnv.ContainerAppEnvironmentId.GetValueAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Missing container app environment id output for '{containerAppEnv.Name}'."); + var (subscriptionId, resourceGroupName) = GetSubscriptionAndResourceGroup(environmentIdValue); + var resourceId = $"{AzurePortalUrls.GetResourceGroupResourceId(subscriptionId, resourceGroupName)}{ContainerAppResourceType}{containerAppName}"; + + return AzurePortalUrls.GetResourceLink(resourceId); + } + + private static (string SubscriptionId, string ResourceGroupName) GetSubscriptionAndResourceGroup(string containerAppEnvironmentId) + { + var environmentId = new ResourceIdentifier(containerAppEnvironmentId); + var subscriptionId = environmentId.SubscriptionId ?? throw new InvalidOperationException($"Container app environment id '{containerAppEnvironmentId}' does not contain a subscription id."); + var resourceGroupName = environmentId.ResourceGroupName ?? throw new InvalidOperationException($"Container app environment id '{containerAppEnvironmentId}' does not contain a resource group name."); + + return (subscriptionId, resourceGroupName); + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/AppSvcUrls.cs b/src/Aspire.Hosting.Azure.AppService/AppSvcUrls.cs new file mode 100644 index 00000000000..2c2bd01f218 --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppService/AppSvcUrls.cs @@ -0,0 +1,39 @@ +// 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 + +using Azure.Core; +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Azure; + +internal static class AppSvcUrls +{ + private const string SiteResourceType = "/providers/Microsoft.Web/sites/"; + private const string SlotPathPrefix = "/slots/"; + + internal static async Task GetPortalLinkAsync(AzureAppServiceEnvironmentResource computerEnv, string siteName, string? deploymentSlot, CancellationToken cancellationToken) + { + var planIdValue = await computerEnv.PlanIdOutputReference.GetValueAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Missing app service plan id output for '{computerEnv.Name}'."); + var (subscriptionId, resourceGroupName) = GetSubscriptionAndResourceGroup(planIdValue); + var resourceId = $"{AzurePortalUrls.GetResourceGroupResourceId(subscriptionId, resourceGroupName)}{SiteResourceType}{siteName}"; + + if (!string.IsNullOrWhiteSpace(deploymentSlot)) + { + resourceId += $"{SlotPathPrefix}{deploymentSlot}"; + } + + return AzurePortalUrls.GetResourceLink(resourceId); + } + + private static (string SubscriptionId, string ResourceGroupName) GetSubscriptionAndResourceGroup(string appServicePlanId) + { + var planId = new ResourceIdentifier(appServicePlanId); + var subscriptionId = planId.SubscriptionId ?? throw new InvalidOperationException($"App Service plan id '{appServicePlanId}' does not contain a subscription id."); + var resourceGroupName = planId.ResourceGroupName ?? throw new InvalidOperationException($"App Service plan id '{appServicePlanId}' does not contain a resource group name."); + + return (subscriptionId, resourceGroupName); + } +} diff --git a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj index 0bf2b531164..4f61d9ccd63 100644 --- a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj +++ b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs index 022ed9cc912..dc927a415d1 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs @@ -55,10 +55,14 @@ public AzureAppServiceWebSiteResource(string name, Action - /// Gets the Azure App Service website name, optionally including the deployment slot suffix. + /// Gets the base Azure App Service website name without any deployment slot suffix. /// /// The pipeline step context. - /// The optional deployment slot name to append to the website name. /// A task that represents the asynchronous operation. The task result contains the website name. - private async Task GetAppServiceWebsiteNameAsync(PipelineStepContext context, string? deploymentSlot = null) + private async Task GetAppServiceWebsiteBaseNameAsync(PipelineStepContext context) { var computerEnv = (AzureAppServiceEnvironmentResource)TargetResource.GetDeploymentTargetAnnotation()!.ComputeEnvironment!; var websiteSuffix = await computerEnv.WebSiteSuffix.GetValueAsync(context.CancellationToken).ConfigureAwait(false); - var websiteName = $"{TargetResource.Name.ToLowerInvariant()}-{websiteSuffix}"; + return TruncateToMaxLength($"{TargetResource.Name.ToLowerInvariant()}-{websiteSuffix}", 60); + } + private static string GetAppServiceWebsiteName(string websiteName, string? deploymentSlot = null) + { if (string.IsNullOrWhiteSpace(deploymentSlot)) { - return TruncateToMaxLength(websiteName, 60); + return websiteName; } - websiteName = TruncateToMaxLength(websiteName, MaxWebSiteNamePrefixLengthWithSlot); - websiteName += $"-{deploymentSlot}"; + var slotHostName = TruncateToMaxLength(websiteName, MaxWebSiteNamePrefixLengthWithSlot); + slotHostName += $"-{deploymentSlot}"; - return TruncateToMaxLength(websiteName, MaxHostPrefixLengthWithSlot); + return TruncateToMaxLength(slotHostName, MaxHostPrefixLengthWithSlot); } private static string TruncateToMaxLength(string value, int maxLength) diff --git a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj index bc11518e795..87a93b17cf2 100644 --- a/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj +++ b/src/Aspire.Hosting.Azure/Aspire.Hosting.Azure.csproj @@ -13,6 +13,7 @@ + @@ -40,7 +41,6 @@ - diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 169f271df5d..62006e71eee 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -149,11 +149,10 @@ private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningCo var location = provisioningContext.Location.Name; var tenantId = provisioningContext.Tenant.TenantId; - var portalUrl = AzurePortalUrls.GetResourceGroupUrl(subscriptionId, resourceGroupName, tenantId); - var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", new MarkdownString(resourceGroupValue)); + ctx.Summary.Add("📦 Resource Group", AzurePortalUrls.GetResourceGroupLink(subscriptionId, resourceGroupName, tenantId)); + ctx.Summary.Add("📜 Deployments", AzurePortalUrls.GetResourceGroupDeploymentsLink(subscriptionId, resourceGroupName, tenantId)); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); } diff --git a/src/Aspire.Hosting.Azure/AzurePortalUrls.cs b/src/Aspire.Hosting.Azure/AzurePortalUrls.cs deleted file mode 100644 index 28c95acb5b0..00000000000 --- a/src/Aspire.Hosting.Azure/AzurePortalUrls.cs +++ /dev/null @@ -1,39 +0,0 @@ -// 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; - -/// -/// Helpers for generating Azure portal URLs. -/// -internal static class AzurePortalUrls -{ - private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id"; - - /// - /// Gets the Azure portal URL for a resource group overview page. - /// - 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"; - } - - /// - /// Gets the Azure portal URL for a deployment details page. - /// - internal static string GetDeploymentUrl(string subscriptionResourceId, string resourceGroupName, string deploymentName) - { - var path = $"{subscriptionResourceId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Resources/deployments/{deploymentName}"; - var encodedPath = Uri.EscapeDataString(path); - return $"{PortalDeploymentOverviewUrl}/{encodedPath}"; - } - - /// - /// Gets the Azure portal URL for a deployment details page using a full deployment resource ID. - /// - internal static string GetDeploymentUrl(global::Azure.Core.ResourceIdentifier deploymentId) - { - return $"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}"; - } -} diff --git a/src/Shared/AzurePortalUrls.cs b/src/Shared/AzurePortalUrls.cs new file mode 100644 index 00000000000..36a074dcc84 --- /dev/null +++ b/src/Shared/AzurePortalUrls.cs @@ -0,0 +1,119 @@ +// 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 + +using Azure.Core; +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Azure; + +/// +/// Helpers for generating Azure portal URLs. +/// +internal static class AzurePortalUrls +{ + private const string PortalRootUrl = "https://portal.azure.com"; + private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id"; + private const string AzurePortalLinkText = "Azure Portal"; + + /// + /// Gets the Azure portal URL for a resource group overview page. + /// + internal static string GetResourceGroupUrl(string subscriptionId, string resourceGroupName, Guid? tenantId = null) + { + return $"{PortalRootUrl}/{GetTenantSegment(tenantId)}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + } + + /// + /// Gets a markdown link to a resource group overview page in the Azure portal. + /// + internal static MarkdownString GetResourceGroupLink(string subscriptionId, string resourceGroupName, Guid? tenantId = null) + { + return GetMarkdownLink(resourceGroupName, GetResourceGroupUrl(subscriptionId, resourceGroupName, tenantId)); + } + + /// + /// Gets the Azure portal URL for a resource group's deployments page. + /// + internal static string GetResourceGroupDeploymentsUrl(string subscriptionId, string resourceGroupName, Guid? tenantId = null) + { + return $"{PortalRootUrl}/{GetTenantSegment(tenantId)}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/deployments"; + } + + /// + /// Gets a markdown link to a resource group's deployments page in the Azure portal. + /// + internal static MarkdownString GetResourceGroupDeploymentsLink(string subscriptionId, string resourceGroupName, Guid? tenantId = null) + { + return GetMarkdownLink(AzurePortalLinkText, GetResourceGroupDeploymentsUrl(subscriptionId, resourceGroupName, tenantId)); + } + + /// + /// Gets the ARM resource ID for a resource group. + /// + internal static string GetResourceGroupResourceId(string subscriptionId, string resourceGroupName) + { + return $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}"; + } + + /// + /// Gets the Azure portal URL for a resource overview page. + /// + internal static string GetResourceUrl(string resourceId, Guid? tenantId = null) + { + return $"{PortalRootUrl}/{GetTenantSegment(tenantId)}/resource{resourceId}/overview"; + } + + /// + /// Gets the Azure portal URL for a resource overview page using a full resource ID. + /// + internal static string GetResourceUrl(ResourceIdentifier resourceId, Guid? tenantId = null) + { + return GetResourceUrl(resourceId.ToString(), tenantId); + } + + /// + /// Gets a markdown link to a resource overview page in the Azure portal. + /// + internal static MarkdownString GetResourceLink(string resourceId, string linkText = AzurePortalLinkText, Guid? tenantId = null) + { + return GetMarkdownLink(linkText, GetResourceUrl(resourceId, tenantId)); + } + + /// + /// Gets a markdown link to a resource overview page in the Azure portal using a full resource ID. + /// + internal static MarkdownString GetResourceLink(ResourceIdentifier resourceId, string linkText = AzurePortalLinkText, Guid? tenantId = null) + { + return GetMarkdownLink(linkText, GetResourceUrl(resourceId, tenantId)); + } + + /// + /// Gets the Azure portal URL for a deployment details page. + /// + internal static string GetDeploymentUrl(string subscriptionResourceId, string resourceGroupName, string deploymentName) + { + var path = $"{subscriptionResourceId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Resources/deployments/{deploymentName}"; + var encodedPath = Uri.EscapeDataString(path); + return $"{PortalDeploymentOverviewUrl}/{encodedPath}"; + } + + /// + /// Gets the Azure portal URL for a deployment details page using a full deployment resource ID. + /// + internal static string GetDeploymentUrl(ResourceIdentifier deploymentId) + { + return $"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}"; + } + + private static string GetTenantSegment(Guid? tenantId) + { + return tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + } + + private static MarkdownString GetMarkdownLink(string linkText, string url) + { + return new($"[{linkText}]({url})"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 9226e6f0ade..393dcb82da3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -11,6 +11,7 @@ #pragma warning disable ASPIRECONTAINERRUNTIME001 // 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.Nodes; +using Azure.Core; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; @@ -30,6 +31,12 @@ namespace Aspire.Hosting.Azure.Tests; public class AzureDeployerTests(ITestOutputHelper testOutputHelper) { + private const string TestSubscriptionId = "12345678-1234-1234-1234-123456789012"; + private const string TestResourceGroupName = "test-rg"; + private static readonly Guid s_testTenantId = Guid.Parse("87654321-4321-4321-4321-210987654321"); + + private static string GetTestResourceId(string resourcePath) => $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}{resourcePath}"; + [Fact] public async Task DeployAsync_PromptsViaInteractionService() { @@ -143,9 +150,9 @@ public async Task DeployAsync_WithBuildOnlyContainers() { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -205,9 +212,9 @@ public async Task DeployAsync_WithAzureStorageResourcesWorks() { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -264,9 +271,9 @@ public async Task DeployAsync_WithContainer_Works() { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -289,9 +296,9 @@ public async Task DeployAsync_WithContainer_Works() // hoisted up for the container resource Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal($"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert - Verify ACR login was not called since no image was pushed Assert.False(fakeContainerRuntime.WasLoginToRegistryCalled); @@ -324,9 +331,9 @@ public async Task DeployAsync_WithDockerfile_Works() { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -349,9 +356,9 @@ public async Task DeployAsync_WithDockerfile_Works() // hoisted up for the container resource Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal($"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert - Verify ACR login was called using IContainerRuntime Assert.True(fakeContainerRuntime.WasLoginToRegistryCalled); @@ -390,9 +397,9 @@ public async Task DeployAsync_WithProjectResource_Works() { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -415,9 +422,9 @@ public async Task DeployAsync_WithProjectResource_Works() // hoisted up for the container resource Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal($"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert - Verify ACR login was called using IContainerRuntime Assert.True(fakeContainerRuntime.WasLoginToRegistryCalled); @@ -435,6 +442,123 @@ public async Task DeployAsync_WithProjectResource_Works() resource.Name == "api"); } + [Fact] + public async Task DeployAsync_WithContainerAppExternalEndpoint_IncludesPortalLinksInSummary() + { + // Arrange + var mockProcessRunner = new MockProcessRunner(); + var fakeContainerRuntime = new FakeContainerRuntime(); + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy); + var armClientProvider = new TestArmClientProvider(deploymentName => + { + return deploymentName switch + { + string name when name.StartsWith("env-acr") => new Dictionary + { + ["name"] = new { type = "String", value = "testregistry" }, + ["loginServer"] = new { type = "String", value = "testregistry.azurecr.io" } + }, + string name when name.StartsWith("env") => new Dictionary + { + ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, + ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, + ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } + }, + _ => [] + }; + }); + ConfigureTestServices(builder, armClientProvider: armClientProvider, processRunner: mockProcessRunner, activityReporter: mockActivityReporter, containerRuntime: fakeContainerRuntime); + + var containerAppEnv = builder.AddAzureContainerAppEnvironment("env"); + builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithComputeEnvironment(containerAppEnv); + + // Act + using var app = builder.Build(); + await app.StartAsync(); + await app.WaitForShutdownAsync(); + + // Assert + Assert.NotEqual(CompletionState.CompletedWithError, mockActivityReporter.ResultCompletionState); + + var summary = mockActivityReporter.PipelineSummary; + Assert.NotNull(summary); + var (containerAppSubscriptionId, containerAppResourceGroupName) = GetSubscriptionAndResourceGroup((string)containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]!); + + AssertSummaryItem(summary, "📦 Resource Group", $"[{TestResourceGroupName}]({AzurePortalUrls.GetResourceGroupUrl(TestSubscriptionId, TestResourceGroupName, s_testTenantId)})"); + AssertSummaryItem(summary, "📜 Deployments", $"[Azure Portal]({AzurePortalUrls.GetResourceGroupDeploymentsUrl(TestSubscriptionId, TestResourceGroupName, s_testTenantId)})"); + AssertSummaryItem( + summary, + "api", + $"[https://api.test.westus.azurecontainerapps.io](https://api.test.westus.azurecontainerapps.io) ([Azure Portal]({AzurePortalUrls.GetResourceUrl($"/subscriptions/{containerAppSubscriptionId}/resourceGroups/{containerAppResourceGroupName}/providers/Microsoft.App/containerApps/api")}))"); + } + + [Fact] + public async Task DeployAsync_WithAppServiceExternalEndpoint_IncludesPortalLinksInSummary() + { + // Arrange + var mockProcessRunner = new MockProcessRunner(); + var fakeContainerRuntime = new FakeContainerRuntime(); + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy); + var armClientProvider = new TestArmClientProvider(deploymentName => + { + return deploymentName switch + { + string name when name.StartsWith("env-acr") => new Dictionary + { + ["name"] = new { type = "String", value = "testregistry" }, + ["loginServer"] = new { type = "String", value = "testregistry.azurecr.io" } + }, + string name when name.StartsWith("env") => new Dictionary + { + ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, + ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, + ["AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-website-identity" }, + ["AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID"] = new { type = "String", value = "test-website-principal-id" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"] = new { type = "String", value = "test-client-id" }, + ["planId"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.Web/serverfarms/testplan" }, + ["webSiteSuffix"] = new { type = "String", value = "website123" }, + ["AZURE_APP_SERVICE_DASHBOARD_URI"] = new { type = "String", value = "https://infra-aspiredashboard-test.azurewebsites.net" } + }, + _ => [] + }; + }); + ConfigureTestServices(builder, armClientProvider: armClientProvider, processRunner: mockProcessRunner, activityReporter: mockActivityReporter, containerRuntime: fakeContainerRuntime); + + var appServiceEnv = builder.AddAzureAppServiceEnvironment("env"); + builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithComputeEnvironment(appServiceEnv); + + // Act + using var app = builder.Build(); + await app.StartAsync(); + await app.WaitForShutdownAsync(); + + // Assert + Assert.NotEqual(CompletionState.CompletedWithError, mockActivityReporter.ResultCompletionState); + + var summary = mockActivityReporter.PipelineSummary; + Assert.NotNull(summary); + var webSiteSuffix = Assert.IsType(appServiceEnv.Resource.Outputs["webSiteSuffix"]); + var websiteName = $"api-{webSiteSuffix}"; + var (planSubscriptionId, planResourceGroupName) = GetSubscriptionAndResourceGroup((string)appServiceEnv.Resource.Outputs["planId"]!); + + AssertSummaryItem(summary, "📜 Deployments", $"[Azure Portal]({AzurePortalUrls.GetResourceGroupDeploymentsUrl(TestSubscriptionId, TestResourceGroupName, s_testTenantId)})"); + AssertSummaryItem( + summary, + "api", + $"[https://{websiteName}.azurewebsites.net](https://{websiteName}.azurewebsites.net) ([Azure Portal]({AzurePortalUrls.GetResourceUrl($"/subscriptions/{planSubscriptionId}/resourceGroups/{planResourceGroupName}/providers/Microsoft.Web/sites/{websiteName}")}))"); + } + [Theory] [InlineData("deploy")] [InlineData("diagnostics")] @@ -458,10 +582,10 @@ public async Task DeployAsync_WithMultipleComputeEnvironments_Works(string step) { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "acaregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "acaregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity") }, ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"] = new { type = "String", value = "aca-client-id" }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "aca.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/acaenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.App/managedEnvironments/acaenv") } }, string name when name.StartsWith("aas-env-acr") => new Dictionary { @@ -472,12 +596,12 @@ public async Task DeployAsync_WithMultipleComputeEnvironments_Works(string step) { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "aasregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "aasregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity") }, ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"] = new { type = "String", value = "aas-client-id" }, - ["AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-website-identity" }, + ["AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-website-identity") }, ["AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID"] = new { type = "String", value = "aas-website-principal-id" }, ["webSiteSuffix"] = new { type = "String", value = ".azurewebsites.net" }, - ["planId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/aasplan" }, + ["planId"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.Web/serverfarms/aasplan") }, ["AZURE_APP_SERVICE_DASHBOARD_URI"] = new { type = "String", value = "https://infra-aspiredashboard-test.azurewebsites.net" } }, _ => [] @@ -521,15 +645,15 @@ public async Task DeployAsync_WithMultipleComputeEnvironments_Works(string step) // Assert ACA environment outputs are properly set Assert.Equal("acaregistry", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("acaregistry.azurecr.io", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity", acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aca-identity"), acaEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("aca.westus.azurecontainerapps.io", acaEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/acaenv", acaEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.App/managedEnvironments/acaenv"), acaEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert AAS environment outputs are properly set Assert.Equal("aasregistry", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("aasregistry.azurecr.io", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/aasplan", aasEnv.Resource.Outputs["planId"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aas-identity"), aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.Web/serverfarms/aasplan"), aasEnv.Resource.Outputs["planId"]); Assert.Equal("aas-client-id", aasEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"]); // Assert - Verify ACR login was called using IContainerRuntime for both registries @@ -692,9 +816,9 @@ public async Task DeployAsync_WithSingleRedisCache_CallsDeployingComputeResource { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, _ => [] }; @@ -718,9 +842,9 @@ public async Task DeployAsync_WithSingleRedisCache_CallsDeployingComputeResource // Assert that container environment outputs are propagated Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal($"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert that compute resources deployment logic was triggered (Redis doesn't require image build/push) var mockImageBuilder = app.Services.GetRequiredService() as MockImageBuilder; @@ -755,7 +879,7 @@ public async Task DeployAsync_WithOnlyAzureResources_PrintsDashboardUrl() string name when name.StartsWith("env") => new Dictionary { ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.App/managedEnvironments/testenv") } }, _ => [] }; @@ -779,7 +903,7 @@ public async Task DeployAsync_WithOnlyAzureResources_PrintsDashboardUrl() // Assert that container environment outputs are propagated Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.App/managedEnvironments/testenv"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert that no compute resources were deployed (no image build/push) var mockImageBuilder = app.Services.GetRequiredService() as MockImageBuilder; @@ -923,9 +1047,9 @@ public async Task DeployAsync_WithAzureFunctionsProject_Works() ["name"] = new { type = "String", value = "env" }, ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"] = new { type = "String", value = "test.westus.azurecontainerapps.io" }, - ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv" } + ["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"] = new { type = "String", value = $"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv" } }, string name when name.StartsWith("funcstorage") => new Dictionary { @@ -945,20 +1069,20 @@ public async Task DeployAsync_WithAzureFunctionsProject_Works() }, string name when name.StartsWith("funcapp-identity") => new Dictionary { - ["id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/funcapp-identity" }, + ["id"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/funcapp-identity") }, ["clientId"] = new { type = "String", value = "funcapp-client-id" }, - ["principalId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["principalId"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, }, string name when name.StartsWith("funcapp-containerapp") => new Dictionary { - ["id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/containerApps/funcapp" }, + ["id"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.App/containerApps/funcapp") }, ["name"] = new { type = "String", value = "funcapp" }, ["fqdn"] = new { type = "String", value = "funcapp.azurecontainerapps.io" }, - ["environmentId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/env" } + ["environmentId"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.App/managedEnvironments/env") } }, string name when name.StartsWith("funcapp") => new Dictionary { - ["identity_id"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, + ["identity_id"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, ["identity_clientId"] = new { type = "String", value = "test-client-id" } }, _ => [] @@ -990,9 +1114,9 @@ public async Task DeployAsync_WithAzureFunctionsProject_Works() // Assert that container environment outputs are propagated Assert.Equal("testregistry", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_NAME"]); Assert.Equal("testregistry.azurecr.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_ENDPOINT"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); + Assert.Equal(GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity"), containerAppEnv.Resource.Outputs["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"]); Assert.Equal("test.westus.azurecontainerapps.io", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN"]); - Assert.Equal("/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); + Assert.Equal($"/subscriptions/{TestSubscriptionId}/resourceGroups/{TestResourceGroupName}/providers/Microsoft.App/managedEnvironments/testenv", containerAppEnv.Resource.Outputs["AZURE_CONTAINER_APPS_ENVIRONMENT_ID"]); // Assert that funcapp outputs are propagated var funcAppDeployment = Assert.IsAssignableFrom(funcApp.Resource.GetDeploymentTargetAnnotation()?.DeploymentTarget); @@ -1037,8 +1161,8 @@ public async Task DeployAsync_WithAzureResourceDependencies_DoesNotHang(string s { ["AZURE_CONTAINER_REGISTRY_NAME"] = new { type = "String", value = "testregistry" }, ["AZURE_CONTAINER_REGISTRY_ENDPOINT"] = new { type = "String", value = "testregistry.azurecr.io" }, - ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" }, - ["planId"] = new { type = "String", value = "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Web/serverfarms/testplan" }, + ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity") }, + ["planId"] = new { type = "String", value = GetTestResourceId("/providers/Microsoft.Web/serverfarms/testplan") }, ["AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID"] = new { type = "String", value = "test-client-id" } }, string name when name.StartsWith("kv") => new Dictionary @@ -1253,6 +1377,26 @@ public async Task DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHt await Verify(allMessages); } + private static void AssertSummaryItem(IReadOnlyList summary, string key, string value) + { + var item = Assert.Single(summary, item => item.Key == key); + Assert.Equal(value, item.Value); + Assert.True(item.EnableMarkdown); + } + + private static (string SubscriptionId, string ResourceGroupName) GetSubscriptionAndResourceGroup(string resourceId) + { + var resourceIdentifier = new ResourceIdentifier(resourceId); + var subscriptionId = resourceIdentifier.SubscriptionId; + var resourceGroupName = resourceIdentifier.ResourceGroupName; + + return (subscriptionId, resourceGroupName) switch + { + ({ Length: > 0 }, { Length: > 0 }) => (subscriptionId, resourceGroupName), + _ => throw new InvalidOperationException($"Resource id '{resourceId}' does not contain both subscription and resource group segments.") + }; + } + private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder, IInteractionService? interactionService = null, IBicepProvisioner? bicepProvisioner = null,