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,