From 546a0ba6f388e0e574d3558943d716321eb5e503 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 7 Jun 2026 13:52:37 -0700 Subject: [PATCH 1/9] Fix ACR pull role assignments Move ACA and App Service environment ACR pull grants into generated AzureRoleAssignmentResource modules so existing registries in other resource groups are scoped correctly. Generalize AzureResourcePreparer so aggregate resources can declare role assignments for internal Azure resources, and expand regression coverage across ACA, App Service, AKS/Foundry, and generated artifacts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...AppEnvironmentAcrPullIdentityAnnotation.cs | 14 +- .../AzureContainerAppEnvironmentResource.cs | 41 ++ .../AzureContainerAppExtensions.cs | 161 ++++--- ...iceEnvironmentAcrPullIdentityAnnotation.cs | 14 +- .../AzureAppServiceEnvironmentExtensions.cs | 132 ++++-- .../AzureAppServiceEnvironmentResource.cs | 42 ++ .../AzureResourcePreparer.cs | 406 ++++++++++-------- .../RoleAssignmentAnnotation.cs | 69 ++- .../AzureAppServiceTests.cs | 12 +- ...eContainerAppEnvironmentExtensionsTests.cs | 9 +- .../AzureResourcePreparerTests.cs | 279 +++++++++++- ...oardAddsEnvironmentResource.verified.bicep | 24 +- ...boardAddsEnvironmentResource.verified.json | 4 +- ...tionInsightsDefaultLocation.verified.bicep | 30 +- ...ationInsightsDefaultLocation.verified.json | 4 +- ...ApplicationInsightsLocation.verified.bicep | 30 +- ...hApplicationInsightsLocation.verified.json | 4 +- ...cationInsightsLocationParam.verified.bicep | 30 +- ...icationInsightsLocationParam.verified.json | 4 +- ...sNormalizesBicepIdentifiers.verified.bicep | 30 +- ...ExistingApplicationInsights.verified.bicep | 30 +- ...hExistingApplicationInsights.verified.json | 4 +- ...mentAddsEnvironmentResource.verified.bicep | 30 +- ...nmentAddsEnvironmentResource.verified.json | 4 +- ...WithExistingPlanAndRegistry.verified.bicep | 24 +- ...vWithExistingPlanAndRegistry.verified.json | 4 +- ...shExistingAppServicePlan#00.verified.bicep | 22 +- ...shExistingAppServicePlan#01.verified.bicep | 30 +- ...renceExistingAppServicePlan.verified.bicep | 30 +- ...erenceExistingAppServicePlan.verified.json | 4 +- ...ServiceEnvironmentsSupported.verified.json | 28 ++ ...erencingExistingEnvironment.verified.bicep | 22 +- ...ferencingExistingEnvironment.verified.json | 3 +- ..._PublishGeneratesThinModule.verified.bicep | 24 +- ...y_PublishGeneratesThinModule.verified.json | 5 +- ...faultRegistryIsRedundant#00.verified.bicep | 21 +- ...faultRegistryIsRedundant#01.verified.bicep | 22 +- ...aceInDifferentResourceGroup.verified.bicep | 22 +- ...paceInDifferentResourceGroup.verified.json | 3 +- ...ConfiguresVnetConfiguration.verified.bicep | 22 +- ...Resource_useAzdNaming=False.verified.bicep | 20 +- ...tResource_useAzdNaming=False.verified.json | 1 + ...tResource_useAzdNaming=True.verified.bicep | 20 +- ...ntResource_useAzdNaming=True.verified.json | 1 + ...NamingPreservesUniqueString.verified.bicep | 20 +- ...tipleVolumesHaveUniqueNames.verified.bicep | 20 +- ...onmentWithCustomRegistry#00.verified.bicep | 20 +- ...ronmentWithCustomRegistry#00.verified.json | 1 + ...nmentWithCustomWorkspace#00.verified.bicep | 20 +- ...onmentWithCustomWorkspace#00.verified.json | 1 + ...onmentWithDashboardDisabled.verified.bicep | 20 +- ...ronmentWithDashboardDisabled.verified.json | 1 + ...ronmentWithDashboardEnabled.verified.bicep | 20 +- ...ironmentWithDashboardEnabled.verified.json | 1 + ...inerAppEnvironmentsSupported.verified.json | 26 ++ ...umesHaveUniqueNamesInBicep#02.verified.txt | 1 + ...umesHaveUniqueNamesInBicep#03.verified.txt | 20 +- 57 files changed, 1173 insertions(+), 733 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentAcrPullIdentityAnnotation.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentAcrPullIdentityAnnotation.cs index 5ccec51388b..4a7a4982da7 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentAcrPullIdentityAnnotation.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentAcrPullIdentityAnnotation.cs @@ -11,11 +11,19 @@ namespace Aspire.Hosting.Azure; /// configured container registry, instead of having Aspire create a new identity and a new AcrPull /// role assignment. /// -/// The user-assigned identity resource to use for the AcrPull role. -internal sealed class AzureContainerAppEnvironmentAcrPullIdentityAnnotation(AzureUserAssignedIdentityResource identity) : IResourceAnnotation +/// The user-assigned identity resource to use for container registry pulls. +/// Indicates whether Aspire should assign the AcrPull role to the identity. +internal sealed class AzureContainerAppEnvironmentAcrPullIdentityAnnotation( + AzureUserAssignedIdentityResource identity, + bool assignAcrPullRole) : IResourceAnnotation { /// - /// Gets the user-assigned identity resource that holds the AcrPull role. + /// Gets the user-assigned identity resource used for container registry pulls. /// public AzureUserAssignedIdentityResource Identity { get; } = identity; + + /// + /// Gets a value indicating whether Aspire should assign the AcrPull role to the identity. + /// + public bool AssignAcrPullRole { get; } = assignAcrPullRole; } diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 36890b66506..3aa6aa263e4 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -199,6 +199,17 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) this, services); + // Deployment prerequisites such as the AcrPull role assignment are owned by the environment, + // not by each generated container app. + // AzureBicepResource.References is the infrastructure dependency list used for module ordering; + // add them here and to every deployment target so image push/deploy steps wait for the + // environment-level infrastructure they need. + var environmentDeploymentPrerequisites = GetEnvironmentDeploymentPrerequisites(); + foreach (var prerequisite in environmentDeploymentPrerequisites) + { + References.Add(prerequisite); + } + foreach (var r in appModel.GetComputeResources()) { // Skip resources that are explicitly targeted to a different compute environment @@ -208,6 +219,7 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) continue; } + AddDeploymentPrerequisites(r, environmentDeploymentPrerequisites); var containerApp = await containerAppEnvironmentContext.CreateContainerAppAsync(r, options.Value, cancellationToken).ConfigureAwait(false); // Capture information about the container registry used by the @@ -224,6 +236,35 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) containerAppEnvironmentContext.LogHttpsUpgradeIfNeeded(); } + private IReadOnlySet GetEnvironmentDeploymentPrerequisites() + { + if (!this.TryGetAnnotationsOfType(out var prerequisiteAnnotations)) + { + return new HashSet(); + } + + return prerequisiteAnnotations.SelectMany(a => a.Resources).ToHashSet(); + } + + private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet prerequisites) + { + if (prerequisites.Count == 0) + { + return; + } + + var newPrerequisites = prerequisites.ToHashSet(); + if (resource.TryGetAnnotationsOfType(out var existingAnnotations)) + { + newPrerequisites.ExceptWith(existingAnnotations.SelectMany(a => a.Resources)); + } + + if (newPrerequisites.Count > 0) + { + resource.Annotations.Add(new DeploymentPrerequisitesAnnotation(newPrerequisites)); + } + } + internal bool UseAzdNamingConvention { get; set; } internal bool UseCompactResourceNaming { get; set; } diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 2ad16ea7027..d6f1177c564 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -15,7 +15,6 @@ using Azure.Provisioning.ContainerRegistry; using Azure.Provisioning.Expressions; using Azure.Provisioning.OperationalInsights; -using Azure.Provisioning.Roles; using Azure.Provisioning.Storage; using Microsoft.Extensions.DependencyInjection; using FileShare = Azure.Provisioning.Storage.FileShare; @@ -27,6 +26,13 @@ namespace Aspire.Hosting; /// public static class AzureContainerAppExtensions { + private static readonly IReadOnlySet s_acrPullRole = new HashSet + { + new( + ContainerRegistryBuiltInRole.AcrPull.ToString(), + ContainerRegistryBuiltInRole.GetBuiltInRoleName(ContainerRegistryBuiltInRole.AcrPull)) + }; + /// /// Adds the necessary infrastructure for Azure Container Apps to the distributed application builder. /// @@ -139,27 +145,14 @@ public static IResourceBuilder AddAzureCon infra.Add(resourceToken); } - UserAssignedIdentity? newIdentity = null; BicepValue managedIdentityIdOutputValue; - if (appEnvResource.TryGetLastAnnotation(out var identityAnnotation)) + if (!appEnvResource.TryGetLastAnnotation(out var identityAnnotation)) { - // The user has supplied an existing identity (commonly via AddAzureUserAssignedIdentity + - // .WithRoleAssignments(acr, AcrPull)). Skip creating env_mi + the AcrPull role assignment - // here and have the env module read the identity id from a parameter wired to the identity - // module's "id" output. - managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); + throw new InvalidOperationException($"No ACR pull identity associated with environment '{appEnvResource.Name}'. This should have been added automatically."); } - else - { - newIdentity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_mi")) - { - Tags = tags - }; - infra.Add(newIdentity); - managedIdentityIdOutputValue = newIdentity.Id.ToBicepExpression(); - } + managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); AzureProvisioningResource? registry = null; if (appEnvResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && @@ -180,15 +173,6 @@ public static IResourceBuilder AddAzureCon var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); infra.Add(containerRegistry); - if (newIdentity is not null) - { - var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, newIdentity); - - // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 - pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, newIdentity.Id, pullRa.RoleDefinitionId); - infra.Add(pullRa); - } - OperationalInsightsWorkspace? laWorkspace = null; if (appEnvResource.TryGetLastAnnotation(out var logAnalyticsReferenceAnnotation) && logAnalyticsReferenceAnnotation.Workspace is AzureProvisioningResource workspace) { @@ -370,7 +354,6 @@ public static IResourceBuilder AddAzureCon { Debug.Assert(resourceToken is not null); - newIdentity?.Name = BicepFunction.Interpolate($"mi-{resourceToken}"); containerRegistry.Name = new FunctionCallExpression( new IdentifierExpression("replace"), new InterpolatedStringExpression([ @@ -428,6 +411,18 @@ public static IResourceBuilder AddAzureCon var registryName = $"{name}-acr"; var defaultRegistry = CreateDefaultAzureContainerRegistry(builder, registryName, containerAppEnvResource); containerAppEnvResource.DefaultContainerRegistry = defaultRegistry; + if (builder.ExecutionContext.IsPublishMode) + { + // The environment module needs the ACR-pull identity id as an input parameter, so the + // identity must exist in the model before the module Bicep is generated. The AcrPull role + // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a + // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance + // to replace the default registry. + var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); + containerAppEnvResource.Annotations.Add(new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); + containerAppEnvResource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); + containerAppEnvResource.Annotations.Add(new RoleAssignmentAnnotation(() => GetContainerRegistryForGeneratedAcrPullRole(containerAppEnvResource), s_acrPullRole)); + } // Create the resource builder first, then attach the registry to avoid recreating builders var appEnvBuilder = builder.ExecutionContext.IsRunMode @@ -502,27 +497,18 @@ private static void ConfigureExistingContainerAppEnvironmentInfrastructure( // FromExisting + ExistingAzureResourceAnnotation (name / resource group scope) wiring. var containerAppEnvironment = (ContainerAppManagedEnvironment)appEnvResource.AddAsExistingResource(infra); - // Container apps still need an identity that can pull from the configured ACR. By default we - // create one here and add an AcrPull role assignment on the registry. When the user has supplied - // their own identity via WithAcrPullIdentity, we skip both — they own role assignments — - // and emit the supplied identity's id as AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID. - UserAssignedIdentity? newIdentity = null; + // Container apps still need an identity that can pull from the configured ACR. The identity + // and AcrPull role assignment are materialized before this module is generated so role + // assignments can be emitted in a correctly scoped module for cross-resource-group registries. BicepValue managedIdentityIdOutputValue; - if (appEnvResource.TryGetLastAnnotation(out var identityAnnotation)) + if (!appEnvResource.TryGetLastAnnotation(out var identityAnnotation)) { - managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); - } - else - { - newIdentity = new UserAssignedIdentity(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_mi")) - { - Tags = tags - }; - infra.Add(newIdentity); - managedIdentityIdOutputValue = newIdentity.Id.ToBicepExpression(); + throw new InvalidOperationException($"No ACR pull identity associated with environment '{appEnvResource.Name}'. This should have been added automatically."); } + managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); + AzureProvisioningResource? registry = null; if (appEnvResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource explicitRegistry) @@ -542,14 +528,6 @@ private static void ConfigureExistingContainerAppEnvironmentInfrastructure( var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); infra.Add(containerRegistry); - if (newIdentity is not null) - { - var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, newIdentity); - // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 - pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, newIdentity.Id, pullRa.RoleDefinitionId); - infra.Add(pullRa); - } - AddSharedContainerAppEnvironmentOutputs(infra, containerRegistry, containerAppEnvironment, managedIdentityIdOutputValue); } @@ -756,11 +734,90 @@ public static IResourceBuilder WithAcrPull ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(identityBuilder); - builder.WithAnnotation(new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource)); + RemoveGeneratedAcrPullIdentity(builder); + builder.WithAnnotation( + new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource, assignAcrPullRole: false), + ResourceAnnotationMutationBehavior.Replace); return builder; } + private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder builder) + { + if (!builder.Resource.TryGetLastAnnotation(out var identityAnnotation) || + !identityAnnotation.AssignAcrPullRole) + { + return; + } + + foreach (var appIdentityAnnotation in builder.Resource.Annotations.OfType() + .Where(a => a.IdentityResource == identityAnnotation.Identity) + .ToArray()) + { + builder.Resource.Annotations.Remove(appIdentityAnnotation); + } + + // WithAcrPullIdentity means the caller owns the identity and its AcrPull permission. Remove the + // hidden identity from the model and let the deferred RoleAssignmentAnnotation resolve to null + // through the replacement AzureContainerAppEnvironmentAcrPullIdentityAnnotation. + builder.ApplicationBuilder.Resources.Remove(identityAnnotation.Identity); + } + + private static AzureProvisioningResource? GetContainerRegistryForGeneratedAcrPullRole(AzureContainerAppEnvironmentResource environment) + { + if (!environment.TryGetLastAnnotation(out var identityAnnotation) || + !identityAnnotation.AssignAcrPullRole) + { + return null; + } + + // Resolve the registry late because WithAzureContainerRegistry can replace the default registry + // after AddAzureContainerAppEnvironment creates the annotation. + return GetContainerRegistryForAcrPullRole(environment); + } + + private static AzureProvisioningResource GetContainerRegistryForAcrPullRole(AzureContainerAppEnvironmentResource environment) + { + if (environment.TryGetLastAnnotation(out var registryReferenceAnnotation)) + { + if (registryReferenceAnnotation.Registry is AzureProvisioningResource explicitRegistry) + { + return explicitRegistry; + } + + throw new InvalidOperationException($"The container registry associated with environment '{environment.Name}' is not an Azure Container Registry."); + } + + return environment.DefaultContainerRegistry ?? + throw new InvalidOperationException($"No container registry associated with environment '{environment.Name}'. This should have been added automatically."); + } + + private static AzureUserAssignedIdentityResource CreateDefaultAcrPullIdentity(IDistributedApplicationBuilder builder, string environmentName) + { + var identity = new AzureUserAssignedIdentityResource(GetUniqueAcrPullIdentityName(builder, environmentName)); + builder.AddResource(identity); + + return identity; + } + + private static string GetUniqueAcrPullIdentityName(IDistributedApplicationBuilder builder, string environmentName) + { + var baseName = $"{environmentName}-acr-pull-identity"; + if (!builder.Resources.TryGetByName(baseName, out _)) + { + return baseName; + } + + for (var i = 2; ; i++) + { + var candidate = $"{baseName}-{i}"; + if (!builder.Resources.TryGetByName(candidate, out _)) + { + return candidate; + } + } + } + private static AzureContainerRegistryResource CreateDefaultAzureContainerRegistry(IDistributedApplicationBuilder builder, string name, AzureContainerAppEnvironmentResource containerAppEnvironment) { var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentAcrPullIdentityAnnotation.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentAcrPullIdentityAnnotation.cs index 366ecd3eeca..2cd46ecf1eb 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentAcrPullIdentityAnnotation.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentAcrPullIdentityAnnotation.cs @@ -11,11 +11,19 @@ namespace Aspire.Hosting.Azure; /// configured container registry, instead of having Aspire create a new identity and a new AcrPull /// role assignment. /// -/// The user-assigned identity resource to use for the AcrPull role. -internal sealed class AzureAppServiceEnvironmentAcrPullIdentityAnnotation(AzureUserAssignedIdentityResource identity) : IResourceAnnotation +/// The user-assigned identity resource to use for container registry pulls. +/// Indicates whether Aspire should assign the AcrPull role to the identity. +internal sealed class AzureAppServiceEnvironmentAcrPullIdentityAnnotation( + AzureUserAssignedIdentityResource identity, + bool assignAcrPullRole) : IResourceAnnotation { /// - /// Gets the user-assigned identity resource that holds the AcrPull role. + /// Gets the user-assigned identity resource used for container registry pulls. /// public AzureUserAssignedIdentityResource Identity { get; } = identity; + + /// + /// Gets a value indicating whether Aspire should assign the AcrPull role to the identity. + /// + public bool AssignAcrPullRole { get; } = assignAcrPullRole; } diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs index ed10e163642..c311d2899bc 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs @@ -14,7 +14,6 @@ using Azure.Provisioning.ContainerRegistry; using Azure.Provisioning.Expressions; using Azure.Provisioning.OperationalInsights; -using Azure.Provisioning.Roles; using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -24,6 +23,13 @@ namespace Aspire.Hosting; /// public static partial class AzureAppServiceEnvironmentExtensions { + private static readonly IReadOnlySet s_acrPullRole = new HashSet + { + new( + ContainerRegistryBuiltInRole.AcrPull.ToString(), + ContainerRegistryBuiltInRole.GetBuiltInRoleName(ContainerRegistryBuiltInRole.AcrPull)) + }; + internal static IDistributedApplicationBuilder AddAzureAppServiceInfrastructureCore(this IDistributedApplicationBuilder builder) { builder.AddAzureProvisioning(); @@ -107,30 +113,16 @@ public static IResourceBuilder AddAzureAppSe infra.Add(tags); - UserAssignedIdentity? newIdentity = null; BicepValue managedIdentityIdOutputValue; BicepValue managedIdentityClientIdOutputValue; - if (resource.TryGetLastAnnotation(out var identityAnnotation)) + if (!resource.TryGetLastAnnotation(out var identityAnnotation)) { - // The user has supplied an existing identity (commonly via AddAzureUserAssignedIdentity + - // .WithRoleAssignments(acr, AcrPull)). Skip creating env_mi + the AcrPull role assignment - // here and have the env module read the identity id/client id from parameters wired to the - // identity module's outputs. - managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); - managedIdentityClientIdOutputValue = identityAnnotation.Identity.ClientId.AsProvisioningParameter(infra); + throw new InvalidOperationException($"No ACR pull identity associated with environment '{resource.Name}'. This should have been added automatically."); } - else - { - newIdentity = new UserAssignedIdentity($"{prefix}_mi") - { - Tags = tags - }; - infra.Add(newIdentity); - managedIdentityIdOutputValue = newIdentity.Id.ToBicepExpression(); - managedIdentityClientIdOutputValue = newIdentity.ClientId.ToBicepExpression(); - } + managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); + managedIdentityClientIdOutputValue = identityAnnotation.Identity.ClientId.AsProvisioningParameter(infra); AzureProvisioningResource? registry = null; if (resource.TryGetLastAnnotation(out var registryReferenceAnnotation) && @@ -151,15 +143,6 @@ public static IResourceBuilder AddAzureAppSe var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); infra.Add(containerRegistry); - if (newIdentity is not null) - { - var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, newIdentity); - - // There's a bug in the CDK, see https://github.com/Azure/azure-sdk-for-net/issues/47265 - pullRa.Name = BicepFunction.CreateGuid(containerRegistry.Id, newIdentity.Id, pullRa.RoleDefinitionId); - infra.Add(pullRa); - } - AppServicePlan plan; if (resource.IsExisting()) { @@ -291,6 +274,18 @@ public static IResourceBuilder AddAzureAppSe { DefaultContainerRegistry = defaultRegistry }; + if (builder.ExecutionContext.IsPublishMode) + { + // The environment module needs the ACR-pull identity id/client id as input parameters, so the + // identity must exist in the model before the module Bicep is generated. The AcrPull role + // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a + // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance + // to replace the default registry. + var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); + resource.Annotations.Add(new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); + resource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); + resource.Annotations.Add(new RoleAssignmentAnnotation(() => GetContainerRegistryForGeneratedAcrPullRole(resource), s_acrPullRole)); + } // Create the resource builder first, then attach the registry to avoid recreating builders var appServiceEnvBuilder = builder.ExecutionContext.IsPublishMode @@ -534,11 +529,90 @@ public static IResourceBuilder WithAcrPullId ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(identityBuilder); - builder.WithAnnotation(new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource)); + RemoveGeneratedAcrPullIdentity(builder); + builder.WithAnnotation( + new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource, assignAcrPullRole: false), + ResourceAnnotationMutationBehavior.Replace); return builder; } + private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder builder) + { + if (!builder.Resource.TryGetLastAnnotation(out var identityAnnotation) || + !identityAnnotation.AssignAcrPullRole) + { + return; + } + + foreach (var appIdentityAnnotation in builder.Resource.Annotations.OfType() + .Where(a => a.IdentityResource == identityAnnotation.Identity) + .ToArray()) + { + builder.Resource.Annotations.Remove(appIdentityAnnotation); + } + + // WithAcrPullIdentity means the caller owns the identity and its AcrPull permission. Remove the + // hidden identity from the model and let the deferred RoleAssignmentAnnotation resolve to null + // through the replacement AzureAppServiceEnvironmentAcrPullIdentityAnnotation. + builder.ApplicationBuilder.Resources.Remove(identityAnnotation.Identity); + } + + private static AzureProvisioningResource? GetContainerRegistryForGeneratedAcrPullRole(AzureAppServiceEnvironmentResource environment) + { + if (!environment.TryGetLastAnnotation(out var identityAnnotation) || + !identityAnnotation.AssignAcrPullRole) + { + return null; + } + + // Resolve the registry late because WithAzureContainerRegistry can replace the default registry + // after AddAzureAppServiceEnvironment creates the annotation. + return GetContainerRegistryForAcrPullRole(environment); + } + + private static AzureProvisioningResource GetContainerRegistryForAcrPullRole(AzureAppServiceEnvironmentResource environment) + { + if (environment.TryGetLastAnnotation(out var registryReferenceAnnotation)) + { + if (registryReferenceAnnotation.Registry is AzureProvisioningResource explicitRegistry) + { + return explicitRegistry; + } + + throw new InvalidOperationException($"The container registry associated with environment '{environment.Name}' is not an Azure Container Registry."); + } + + return environment.DefaultContainerRegistry ?? + throw new InvalidOperationException($"No container registry associated with environment '{environment.Name}'. This should have been added automatically."); + } + + private static AzureUserAssignedIdentityResource CreateDefaultAcrPullIdentity(IDistributedApplicationBuilder builder, string environmentName) + { + var identity = new AzureUserAssignedIdentityResource(GetUniqueAcrPullIdentityName(builder, environmentName)); + builder.AddResource(identity); + + return identity; + } + + private static string GetUniqueAcrPullIdentityName(IDistributedApplicationBuilder builder, string environmentName) + { + var baseName = $"{environmentName}-acr-pull-identity"; + if (!builder.Resources.TryGetByName(baseName, out _)) + { + return baseName; + } + + for (var i = 2; ; i++) + { + var candidate = $"{baseName}-{i}"; + if (!builder.Resources.TryGetByName(candidate, out _)) + { + return candidate; + } + } + } + private static AzureContainerRegistryResource CreateDefaultAzureContainerRegistry(IDistributedApplicationBuilder builder, string name) { var resource = new AzureContainerRegistryResource(name, ContainerRegistryInfrastructure.ConfigureContainerRegistry); diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index ba27ed9e8eb..515827abf29 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREAZURE001 +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -199,6 +200,17 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) this, services); + // Deployment prerequisites such as the AcrPull role assignment are owned by the environment, + // not by each generated website. + // AzureBicepResource.References is the infrastructure dependency list used for module ordering; + // add them here and to every deployment target so image push/deploy steps wait for the + // environment-level infrastructure they need. + var environmentDeploymentPrerequisites = GetEnvironmentDeploymentPrerequisites(); + foreach (var prerequisite in environmentDeploymentPrerequisites) + { + References.Add(prerequisite); + } + // Annotate the environment with its context Annotations.Add(new AzureAppServiceEnvironmentContextAnnotation(appServiceEnvironmentContext)); @@ -217,6 +229,7 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) continue; } + AddDeploymentPrerequisites(resource, environmentDeploymentPrerequisites); var website = await appServiceEnvironmentContext.CreateAppServiceAsync(resource, provisioningOptions.Value, cancellationToken).ConfigureAwait(false); resource.Annotations.Add(new DeploymentTargetAnnotation(website) @@ -230,6 +243,35 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) appServiceEnvironmentContext.LogHttpsUpgradeIfNeeded(); } + private IReadOnlySet GetEnvironmentDeploymentPrerequisites() + { + if (!this.TryGetAnnotationsOfType(out var prerequisiteAnnotations)) + { + return new HashSet(); + } + + return prerequisiteAnnotations.SelectMany(a => a.Resources).ToHashSet(); + } + + private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet prerequisites) + { + if (prerequisites.Count == 0) + { + return; + } + + var newPrerequisites = prerequisites.ToHashSet(); + if (resource.TryGetAnnotationsOfType(out var existingAnnotations)) + { + newPrerequisites.ExceptWith(existingAnnotations.SelectMany(a => a.Resources)); + } + + if (newPrerequisites.Count > 0) + { + resource.Annotations.Add(new DeploymentPrerequisitesAnnotation(newPrerequisites)); + } + } + private async Task PrintDashboardUrlAsync(PipelineStepContext context) { var dashboardUri = await DashboardUriReference.GetValueAsync(context.CancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index 02448c02bb5..ed003441ded 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -28,14 +28,15 @@ internal async Task PrepareResourcesAsync(DistributedApplicationModel model, Can return; } - if (!EnvironmentSupportsIdentitiesAndAssignments()) + var supportsTargetedRoleAssignments = EnvironmentSupportsIdentitiesAndAssignments(model); + if (!supportsTargetedRoleAssignments) { // If the app infrastructure does not support targeted identities and role assignments, then we need to ensure that // there are no identity or role assignment annotations in the app model because they won't be honored otherwise. EnsureNoIdentityOrRoleAssignmentAnnotations(model); } - await BuildRoleAssignmentAnnotations(model, azureResources, cancellationToken).ConfigureAwait(false); + await BuildRoleAssignmentAnnotations(model, azureResources, supportsTargetedRoleAssignments, cancellationToken).ConfigureAwait(false); // set the ProvisioningBuildOptions on the resource, if necessary foreach (var r in azureResources) @@ -77,11 +78,14 @@ internal async Task PrepareResourcesAsync(DistributedApplicationModel model, Can return azureResources; } - private bool EnvironmentSupportsIdentitiesAndAssignments() + private bool EnvironmentSupportsIdentitiesAndAssignments(DistributedApplicationModel model) { // run mode always supports targeted role assignments - // publish mode only supports targeted role assignments if the environment supports it - return executionContext.IsRunMode || options.Value.SupportsTargetedRoleAssignments; + // publish mode supports targeted role assignments when the active publisher opted in, or when + // a compute environment resource is already in the model. The option is the normal extension + // registration signal; the resource check preserves direct model construction paths where the + // environment-specific pipeline still knows how to attach identities and deployment prerequisites. + return executionContext.IsRunMode || options.Value.SupportsTargetedRoleAssignments || model.Resources.OfType().Any(); } private static void EnsureNoIdentityOrRoleAssignmentAnnotations(DistributedApplicationModel appModel) @@ -100,161 +104,38 @@ private static void EnsureNoIdentityOrRoleAssignmentAnnotations(DistributedAppli } } - private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel appModel, List<(IResource Resource, IAzureResource AzureResource)> azureResources, CancellationToken cancellationToken) + private async Task BuildRoleAssignmentAnnotations( + DistributedApplicationModel appModel, + List<(IResource Resource, IAzureResource AzureResource)> azureResources, + bool supportsTargetedRoleAssignments, + CancellationToken cancellationToken) { var globalRoleAssignments = new Dictionary>(); - if (!EnvironmentSupportsIdentitiesAndAssignments()) + var provisioningResources = azureResources.Select(r => r.AzureResource).OfType().ToArray(); + if (!supportsTargetedRoleAssignments) { - // when the app infrastructure doesn't support targeted role assignments, just copy all the default role assignments to applied role assignments - foreach (var resource in azureResources.Select(r => r.AzureResource).OfType()) - { - if (resource.TryGetLastAnnotation(out var defaultRoleAssignments)) - { - AppendGlobalRoleAssignments(globalRoleAssignments, resource, defaultRoleAssignments.Roles); - } - } + AddDefaultRoleAssignments(provisioningResources, globalRoleAssignments); } else { - // when the app infrastructure supports targeted role assignments, walk the resource graph and - // - if in RunMode - // - if a compute resource has RoleAssignmentAnnotations, add them to globalRoleAssignments on the referenced Azure resource - // - if the resource doesn't, copy the DefaultRoleAssignments to globalRoleAssignments - // - // - if in PublishMode - // - if a compute resource has RoleAssignmentAnnotations, use them - // - if the resource doesn't, copy the DefaultRoleAssignments to RoleAssignmentAnnotations to apply the defaults - var resourceSnapshot = appModel.GetComputeResources() - .Concat(appModel.Resources - .OfType() - .Where(r => !r.IsExcludedFromPublish())) - .ToArray(); // avoid modifying the collection while iterating - foreach (var resource in resourceSnapshot) + foreach (var resource in GetRoleAssignmentProcessingResources(appModel)) { var prerequisiteResources = new HashSet(); var directDependencies = await resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); - var azureReferences = new HashSet(directDependencies.OfType()); - - var azureReferencesWithRoleAssignments = - (resource.TryGetAnnotationsOfType(out var annotations) - ? annotations - : []) - .ToLookup(a => a.Target); - foreach (var azureReference in azureReferences.OfType()) - { - if (azureReference.IsContainer() || azureReference.IsEmulator()) - { - // Skip emulators - continue; - } - - var roleAssignments = azureReferencesWithRoleAssignments[azureReference]; - if (roleAssignments.Any()) - { - if (executionContext.IsRunMode) - { - // in RunMode, we need to add the role assignments to the resource - AppendGlobalRoleAssignments(globalRoleAssignments, azureReference, roleAssignments.SelectMany(a => a.Roles)); - } - // in PublishMode, this is a no-op since GetAllRoleAssignments will handle the role assignments - } - else if (azureReference.TryGetLastAnnotation(out var defaults)) - { - if (executionContext.IsRunMode) - { - // in RunMode, we copy the default role assignments to the Azure reference, - // even if the roles are empty, since empty roles are used by some resources - like databases - AppendGlobalRoleAssignments(globalRoleAssignments, azureReference, defaults.Roles); - } - else - { - // in PublishMode, we copy the default role assignments to the compute resource - resource.Annotations.Add(new RoleAssignmentAnnotation(azureReference, defaults.Roles)); - } - } - - // Find private endpoints that target Azure resources referenced by this compute resource. - // These must be provisioned before the compute resource is deployed. - if (azureReference.TryGetAnnotationsOfType(out var peAnnotations)) - { - foreach (var peAnnotation in peAnnotations) - { - prerequisiteResources.Add(peAnnotation.PrivateEndpointResource); - } - } - } - // A direct dependency that is not itself an Azure resource can still "front" one - // (e.g. a Foundry hosted agent's node app fronts its owning Foundry account). Such a - // resource carries ReferenceRoleAssignmentAnnotation(s) declaring that any resource - // referencing it should be granted roles on a transitive Azure target the normal - // IAzureResource-only reference walk above cannot reach. Fold those implied targets - // into the same role-assignment path so the consumer gets an identity + role bicep - // exactly as it would for a direct Azure reference. - foreach (var dependency in directDependencies) - { - if (!dependency.TryGetAnnotationsOfType(out var impliedRoleAssignments)) - { - continue; - } - - foreach (var impliedRoleAssignment in impliedRoleAssignments) - { - var target = impliedRoleAssignment.Target; - if (target.IsContainer() || target.IsEmulator()) - { - continue; - } - - if (executionContext.IsRunMode) - { - AppendGlobalRoleAssignments(globalRoleAssignments, target, impliedRoleAssignment.Roles); - } - else - { - // In PublishMode, materialize as an explicit RoleAssignmentAnnotation so - // GetAllRoleAssignments (which groups by target and unions roles) picks it - // up alongside any roles the consumer already declares for the same target. - resource.Annotations.Add(new RoleAssignmentAnnotation(target, impliedRoleAssignment.Roles)); - } - } - } + ProcessDirectAzureReferences(resource, directDependencies.OfType().Distinct(), globalRoleAssignments, prerequisiteResources); + ProcessReferenceRoleAssignments(resource, directDependencies, globalRoleAssignments); - // in PublishMode with SupportsTargetedRoleAssignments, we need to create the identity and role assignment resources - // if the resource references any Azure resources, or has role assignments to Azure resources if (executionContext.IsPublishMode) { - var roleAssignments = GetAllRoleAssignments(resource); - if (roleAssignments.Count > 0) - { - var (identityResource, roleAssignmentResources) = CreateIdentityAndRoleAssignmentResources(resource, roleAssignments); - - if (resource != identityResource) - { - // Only add the AppIdentityAnnotation if the resource doesn't already have one - if (!resource.TryGetLastAnnotation(out var existingAppIdentityAnnotation) || - existingAppIdentityAnnotation.IdentityResource != identityResource) - { - resource.Annotations.Add(new AppIdentityAnnotation(identityResource)); - } - - // add the identity resource to the resource collection so it can be provisioned - // but only if it's not already there - if (!appModel.Resources.Contains(identityResource)) - { - appModel.Resources.Add(identityResource); - } - } - - foreach (var roleAssignmentResource in roleAssignmentResources) - { - prerequisiteResources.Add(AddOrGetRoleAssignmentResource(appModel, roleAssignmentResource)); - } - } + // The two processing steps above may add RoleAssignmentAnnotation instances for + // defaults and implied references. Materialize publish resources only after that + // mutation so GetAllRoleAssignments sees the complete set for this owner. + CreatePublishRoleAssignmentResources(appModel, resource, prerequisiteResources); } - // Add prerequisite infrastructure resources on the compute resource. + // Add prerequisite infrastructure resources on the owner resource. // Deployment infrastructure subscribers will transfer these to deployment target References // so AzureBicepResource dependency wiring can apply provision ordering. AddDeploymentPrerequisitesAnnotation(resource, prerequisiteResources); @@ -262,15 +143,9 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap if (executionContext.IsRunMode) { - // in RunMode, any Azure resources that are not referenced by a compute resource should have their default role assignments applied - foreach (var azureResource in azureResources.Select(r => r.AzureResource).OfType()) - { - if (!globalRoleAssignments.TryGetValue(azureResource, out _) && - azureResource.TryGetLastAnnotation(out var defaultRoleAssignments)) - { - AppendGlobalRoleAssignments(globalRoleAssignments, azureResource, defaultRoleAssignments.Roles); - } - } + // Any Azure resource that was not claimed by a direct reference still gets its defaults + // in Run mode, preserving the deployment-principal behavior used for F5 provisioning. + AddDefaultRoleAssignments(provisioningResources.Where(resource => !globalRoleAssignments.ContainsKey(resource)), globalRoleAssignments); } } @@ -280,33 +155,221 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap } } - private static Dictionary> GetAllRoleAssignments(IResource resource) + private static IResource[] GetRoleAssignmentProcessingResources(DistributedApplicationModel appModel) + { + // Snapshot the owners before processing because publish materialization can add generated + // identities and role-assignment resources to the model. Aggregate resources are included + // because they can declare role needs for internal Azure resources even though they are not + // compute resources themselves. + return appModel.GetComputeResources() + .Concat(appModel.Resources + .OfType() + .Where(r => !r.IsExcludedFromPublish())) + .Concat(appModel.Resources + .Where(r => !r.IsExcludedFromPublish() && r.HasAnnotationOfType())) + .Distinct() + .ToArray(); + } + + private void ProcessDirectAzureReferences( + IResource resource, + IEnumerable azureReferences, + Dictionary> globalRoleAssignments, + HashSet prerequisiteResources) + { + var roleAssignmentsByTarget = GetResolvedRoleAssignments(resource).ToLookup(a => a.Target); + + foreach (var azureReference in azureReferences) + { + if (ShouldSkipRoleAssignmentTarget(azureReference)) + { + continue; + } + + AddPrivateEndpointPrerequisites(azureReference, prerequisiteResources); + + var explicitRoleAssignments = roleAssignmentsByTarget[azureReference]; + if (explicitRoleAssignments.Any()) + { + if (executionContext.IsRunMode) + { + // Run mode assigns roles only for direct Azure references. Aggregate annotations + // whose targets are not direct dependencies are publish-only, which avoids granting + // unexpected deployment-principal permissions during local provisioning. + AppendRoleAssignments(globalRoleAssignments, azureReference, explicitRoleAssignments.SelectMany(a => a.Roles)); + } + + continue; + } + + if (!azureReference.TryGetLastAnnotation(out var defaults)) + { + continue; + } + + if (executionContext.IsRunMode) + { + // Empty defaults are meaningful for resources like databases: the key marks the target + // as claimed so the Run-mode fallback does not add broader parent defaults later. + AppendRoleAssignments(globalRoleAssignments, azureReference, defaults.Roles); + } + else + { + resource.Annotations.Add(new RoleAssignmentAnnotation(azureReference, defaults.Roles)); + } + } + } + + private void ProcessReferenceRoleAssignments( + IResource resource, + IReadOnlySet directDependencies, + Dictionary> globalRoleAssignments) + { + // A direct dependency that is not itself an Azure resource can still "front" one + // (e.g. a Foundry hosted agent's node app fronts its owning Foundry account). Such a + // resource carries ReferenceRoleAssignmentAnnotation(s) declaring that any resource + // referencing it should be granted roles on a transitive Azure target the normal + // IAzureResource-only reference walk above cannot reach. + foreach (var dependency in directDependencies) + { + if (!dependency.TryGetAnnotationsOfType(out var impliedRoleAssignments)) + { + continue; + } + + foreach (var impliedRoleAssignment in impliedRoleAssignments) + { + var target = impliedRoleAssignment.Target; + if (ShouldSkipRoleAssignmentTarget(target)) + { + continue; + } + + if (executionContext.IsRunMode) + { + AppendRoleAssignments(globalRoleAssignments, target, impliedRoleAssignment.Roles); + } + else + { + // Publish materialization reads RoleAssignmentAnnotation, so convert implied + // reference grants into the same shape as direct WithRoleAssignments calls. + resource.Annotations.Add(new RoleAssignmentAnnotation(target, impliedRoleAssignment.Roles)); + } + } + } + } + + private void CreatePublishRoleAssignmentResources( + DistributedApplicationModel appModel, + IResource resource, + HashSet prerequisiteResources) + { + var roleAssignments = GetAllRoleAssignments(resource); + if (roleAssignments.Count == 0) + { + return; + } + + var (identityResource, roleAssignmentResources) = CreateIdentityAndRoleAssignmentResources(resource, roleAssignments); + + if (resource != identityResource) + { + EnsureIdentityResource(appModel, resource, identityResource); + } + + foreach (var roleAssignmentResource in roleAssignmentResources) + { + prerequisiteResources.Add(AddOrGetRoleAssignmentResource(appModel, roleAssignmentResource)); + } + } + + private void EnsureIdentityResource(DistributedApplicationModel appModel, IResource ownerResource, AzureUserAssignedIdentityResource identityResource) + { + if (!ownerResource.TryGetLastAnnotation(out var existingAppIdentityAnnotation) || + existingAppIdentityAnnotation.IdentityResource != identityResource) + { + ownerResource.Annotations.Add(new AppIdentityAnnotation(identityResource)); + } + + if (!appModel.Resources.Contains(identityResource)) + { + identityResource.ProvisioningBuildOptions ??= options.Value.ProvisioningBuildOptions; + appModel.Resources.Add(identityResource); + } + } + + private static void AddDefaultRoleAssignments( + IEnumerable resources, + Dictionary> roleAssignments) { - var result = new Dictionary>(); - if (resource.TryGetAnnotationsOfType(out var roleAssignments)) + foreach (var resource in resources) { - foreach (var g in roleAssignments.GroupBy(r => r.Target)) + if (resource.TryGetLastAnnotation(out var defaultRoleAssignments)) { - // Deduplicate roles per target. A target can accumulate multiple RoleAssignmentAnnotations - // (e.g. an implied ReferenceRoleAssignmentAnnotation from two hosted agents on the same - // Foundry account, plus a direct reference). Emitting the same RoleDefinition twice would - // produce two RoleAssignment bicep resources with the same identifier ("{prefix}_{roleName}") - // and fail bicep compilation. This mirrors the RunMode path, which unions into a HashSet. - result[g.Key] = g.SelectMany(r => r.Roles).Distinct(); + AppendRoleAssignments(roleAssignments, resource, defaultRoleAssignments.Roles); } } + } + + private static void AddPrivateEndpointPrerequisites(AzureProvisioningResource azureReference, HashSet prerequisiteResources) + { + if (!azureReference.TryGetAnnotationsOfType(out var peAnnotations)) + { + return; + } + + foreach (var peAnnotation in peAnnotations) + { + prerequisiteResources.Add(peAnnotation.PrivateEndpointResource); + } + } + + private static bool ShouldSkipRoleAssignmentTarget(AzureProvisioningResource target) => target.IsContainer() || target.IsEmulator(); + + private static Dictionary> GetAllRoleAssignments(IResource resource) + { + var result = new Dictionary>(); + + foreach (var (target, roles) in GetResolvedRoleAssignments(resource)) + { + // Use the same HashSet accumulator as Run mode so duplicate annotations for the same + // target cannot produce duplicate Bicep role assignment names. Creating the key before + // unioning is intentional: an empty role set must remain observable after a caller uses + // it to suppress defaults. + AppendRoleAssignments(result, target, roles); + } + return result; } + private static IEnumerable<(AzureProvisioningResource Target, IReadOnlySet Roles)> GetResolvedRoleAssignments(IResource resource) + { + if (!resource.TryGetAnnotationsOfType(out var roleAssignments)) + { + yield break; + } + + foreach (var roleAssignment in roleAssignments) + { + // A deferred role assignment can intentionally resolve to null. This lets a resource keep one + // annotation in the model while late builder calls, such as supplying a BYO identity, opt out + // of the generated role assignment without mutating delegate-backed annotations. + if (roleAssignment.TryGetTarget(out var target)) + { + yield return (target, roleAssignment.Roles); + } + } + } + private (AzureUserAssignedIdentityResource IdentityResource, List RoleAssignmentResources) CreateIdentityAndRoleAssignmentResources( IResource resource, - Dictionary> roleAssignments) + Dictionary> roleAssignments) { AzureUserAssignedIdentityResource identityResource; - // If we're currently targeting an AzureUserAssignedIdentityResource, we can use it as the identity resource - // for the role assignments. If we are targeting a compute resource that has an AppIdentityAnnotation, we can - // use the identity resource from that annotation. Otherwise, create a new identity resource to use for role assignments. + // If the owner is an AzureUserAssignedIdentityResource, it is its own role-assignment identity. + // Otherwise prefer an explicit AppIdentityAnnotation and create a hidden identity only when the + // owner has not declared which identity should receive the roles. if (resource is AzureUserAssignedIdentityResource existingIdentityResource) { identityResource = existingIdentityResource; @@ -330,7 +393,7 @@ private static Dictionary private List CreateRoleAssignmentsResources( IResource resource, - Dictionary> roleAssignments, + Dictionary> roleAssignments, AzureUserAssignedIdentityResource appIdentityResource) { var roleAssignmentResources = new List(); @@ -365,7 +428,6 @@ private void AddRoleAssignmentsInfrastructure( IEnumerable roles, AzureUserAssignedIdentityResource appIdentityResource) { - var context = new AddRoleAssignmentsContext( infra, executionContext, @@ -401,12 +463,12 @@ private sealed class AddRoleAssignmentsContext( public DistributedApplicationExecutionContext ExecutionContext => executionContext; } - private static void AppendGlobalRoleAssignments(Dictionary> globalRoleAssignments, AzureProvisioningResource azureResource, IEnumerable newRoles) + private static void AppendRoleAssignments(Dictionary> roleAssignments, AzureProvisioningResource azureResource, IEnumerable newRoles) { - if (!globalRoleAssignments.TryGetValue(azureResource, out var existingRoles)) + if (!roleAssignments.TryGetValue(azureResource, out var existingRoles)) { existingRoles = new HashSet(); - globalRoleAssignments[azureResource] = existingRoles; + roleAssignments[azureResource] = existingRoles; } existingRoles.UnionWith(newRoles); diff --git a/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs b/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs index 5898a685a87..483b1a5c9f5 100644 --- a/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs +++ b/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -8,20 +9,78 @@ namespace Aspire.Hosting.Azure; /// /// Specifies the roles that the current resource should be assigned to the target Azure resource. /// -/// The Azure resource that the current resource will interact with. -/// The roles that the current resource should be assigned to . /// /// This annotation is applied to compute resources (e.g., Projects or Containers) that need to interact with Azure resources. /// -public class RoleAssignmentAnnotation(AzureProvisioningResource target, IReadOnlySet roles) : IResourceAnnotation +public class RoleAssignmentAnnotation : IResourceAnnotation { + private readonly Func _targetResolver; + private bool _targetResolved; + private AzureProvisioningResource? _target; + + /// + /// Initializes a new instance of the class. + /// + /// The Azure resource that the current resource will interact with. + /// The roles that the current resource should be assigned to . + public RoleAssignmentAnnotation(AzureProvisioningResource target, IReadOnlySet roles) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(roles); + + _targetResolver = () => target; + Roles = roles; + } + + /// + /// Initializes a new instance of the class with a target resolved during Azure preparation. + /// + /// Resolves the Azure resource that the current resource will interact with, or to skip the role assignment. + /// The roles that the current resource should be assigned to the resolved target. + /// + /// Use this overload when a resource owns role assignments for internal Azure resources whose final target can be + /// changed by later builder calls. + /// + public RoleAssignmentAnnotation(Func targetResolver, IReadOnlySet roles) + { + ArgumentNullException.ThrowIfNull(targetResolver); + ArgumentNullException.ThrowIfNull(roles); + + _targetResolver = targetResolver; + Roles = roles; + } + /// /// The Azure resource that the current resource will interact with. /// - public AzureProvisioningResource Target { get; } = target; + public AzureProvisioningResource Target => + TryGetTarget(out var target) + ? target + : throw new InvalidOperationException("The role assignment target could not be resolved."); /// /// Gets the set of roles the current resource should be assigned to the target Azure resource. /// - public IReadOnlySet Roles { get; } = roles; + public IReadOnlySet Roles { get; } + + internal bool TryGetTarget([NotNullWhen(true)] out AzureProvisioningResource? target) + { + if (!_targetResolved) + { + // Deferred targets can depend on later builder mutations such as WithAzureContainerRegistry. + // Cache the first non-null value so grouping and de-duplication use a stable target, but + // do not let an early optional probe permanently suppress a target that resolves later. + target = _targetResolver(); + if (target is not null) + { + _target = target; + _targetResolved = true; + } + + return target is not null; + } + + target = _target!; + return true; + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs index d8730e90796..d5ce2a94283 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIRECOMPUTE002 #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREAZURE003 // 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 Aspire.Hosting.ApplicationModel; @@ -481,7 +482,14 @@ public async Task AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWi .WithAzureContainerRegistry(acr) .WithDashboard(false); - var (manifest, bicep) = await GetManifestWithBicep(env.Resource); + var model = new DistributedApplicationModel([env.Resource]); + var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(model, env.Resource); + + var roleAssignment = Assert.Single(model.Resources.OfType(), + r => r.Name == "env-roles-acr"); + Assert.Same(acr.Resource, roleAssignment.TargetAzureResource); + Assert.Same(env.Resource, roleAssignment.OwnerResource); + Assert.Same(sharedResourceGroup.Resource, roleAssignment.Scope?.ResourceGroup); await Verify(bicep, extension: "bicep") .AppendContentAsFile(manifest.ToString(), "json"); @@ -1283,7 +1291,7 @@ await Verify(manifest.ToString(), "json") } private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) => - AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true); + AzureManifestUtils.GetManifestWithBicep(resource); private static async Task> CreateStepsAsync(DistributedApplication app, AzureAppServiceEnvironmentResource resource) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs index a993f1e0ecd..dabcfdf6e27 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs @@ -193,7 +193,14 @@ public async Task AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinM .AsExisting(environmentName, sharedResourceGroup) .WithAzureContainerRegistry(acr); - var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(env.Resource); + var model = new DistributedApplicationModel([env.Resource]); + var (manifest, bicep) = await AzureManifestUtils.GetManifestWithBicep(model, env.Resource); + + var roleAssignment = Assert.Single(model.Resources.OfType(), + r => r.Name == "env-roles-acr"); + Assert.Same(acr.Resource, roleAssignment.TargetAzureResource); + Assert.Same(env.Resource, roleAssignment.OwnerResource); + Assert.Same(sharedResourceGroup.Resource, roleAssignment.Scope?.ResourceGroup); await Verify(bicep, extension: "bicep") .AppendContentAsFile(manifest.ToString(), "json"); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs index 63dfc210cfd..01f7fd80131 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs @@ -209,6 +209,259 @@ public async Task FindsAzureReferencesFromArguments() Assert.Equal(defaultAssignments.Roles, apiRoleAssignments.Roles); } + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_CreatesRoleResourcesForAggregateOwner() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + var storage = builder.AddAzureStorage("storage"); + var keyVault = builder.AddAzureKeyVault("keyvault"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => keyVault.Resource, + CreateKeyVaultRoleDefinitions(KeyVaultBuiltInRole.KeyVaultSecretsUser))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var storageRoles = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(storage.Resource, storageRoles.TargetAzureResource); + Assert.Same(aggregate, storageRoles.OwnerResource); + Assert.Same(identity.Resource, storageRoles.IdentityResource); + + var keyVaultRoles = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-keyvault"); + Assert.Same(keyVault.Resource, keyVaultRoles.TargetAzureResource); + Assert.Same(aggregate, keyVaultRoles.OwnerResource); + Assert.Same(identity.Resource, keyVaultRoles.IdentityResource); + + var prerequisites = Assert.Single(aggregate.Annotations.OfType()).Resources; + Assert.Contains(storageRoles, prerequisites); + Assert.Contains(keyVaultRoles, prerequisites); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_CreatesRoleResourcesWithoutComputeEnvironmentWhenTargetedAssignmentsSupported() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + var storage = builder.AddAzureStorage("storage"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.DoesNotContain(model.Resources, resource => resource is IAzureComputeEnvironmentResource); + + var roleAssignment = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(storage.Resource, roleAssignment.TargetAzureResource); + Assert.Same(aggregate, roleAssignment.OwnerResource); + Assert.Same(identity.Resource, roleAssignment.IdentityResource); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_ThrowsForAggregateOwnerWhenTargetedAssignmentsUnsupported() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var storage = builder.AddAzureStorage("storage"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var ex = await Assert.ThrowsAsync(() => ExecuteBeforeStartHooksAsync(app, default)); + Assert.Contains("role assignments", ex.Message); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_SkipsNullTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => null, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.DoesNotContain(model.Resources.OfType(), r => r.OwnerResource == aggregate); + Assert.Empty(aggregate.Annotations.OfType()); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_DoesNotCacheNullTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + var storage = builder.AddAzureStorage("storage"); + + AzureProvisioningResource? target = null; + var roleAssignmentAnnotation = new RoleAssignmentAnnotation( + () => target, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader)); + + Assert.Throws(() => roleAssignmentAnnotation.Target); + target = storage.Resource; + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(roleAssignmentAnnotation); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleAssignment = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(storage.Resource, roleAssignment.TargetAzureResource); + Assert.Same(aggregate, roleAssignment.OwnerResource); + Assert.Same(identity.Resource, roleAssignment.IdentityResource); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_CachesFirstNonNullTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + var storage = builder.AddAzureStorage("storage"); + var keyVault = builder.AddAzureKeyVault("keyvault"); + + AzureProvisioningResource? target = storage.Resource; + var roleAssignmentAnnotation = new RoleAssignmentAnnotation( + () => target, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader)); + + Assert.Same(storage.Resource, roleAssignmentAnnotation.Target); + target = keyVault.Resource; + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(roleAssignmentAnnotation); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleAssignment = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(storage.Resource, roleAssignment.TargetAzureResource); + Assert.DoesNotContain(model.Resources.OfType(), r => r.Name == "aggregate-roles-keyvault"); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_DeduplicatesRolesForAggregateOwner() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var identity = builder.AddAzureUserAssignedIdentity("aggregate-identity"); + var storage = builder.AddAzureStorage("storage"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new AppIdentityAnnotation(identity.Resource)); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var roleAssignment = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(storage.Resource, roleAssignment.TargetAzureResource); + Assert.Same(aggregate, roleAssignment.OwnerResource); + + var manifest = await GetManifestWithBicep(roleAssignment, skipPreparer: true); + var roleAssignmentCount = System.Text.RegularExpressions.Regex.Matches(manifest.BicepText, "Microsoft.Authorization/roleAssignments@").Count; + Assert.Equal(1, roleAssignmentCount); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_PublishMode_CreatesIdentityForAggregateOwnerWithoutAppIdentity() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.Services.Configure(options => options.SupportsTargetedRoleAssignments = true); + + var storage = builder.AddAzureStorage("storage"); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + var generatedIdentity = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-identity"); + var identityAnnotation = Assert.Single(aggregate.Annotations.OfType()); + Assert.Same(generatedIdentity, identityAnnotation.IdentityResource); + + var roleAssignment = Assert.Single(model.Resources.OfType(), r => r.Name == "aggregate-roles-storage"); + Assert.Same(generatedIdentity, roleAssignment.IdentityResource); + Assert.Same(aggregate, roleAssignment.OwnerResource); + } + + [Fact] + public async Task DeferredRoleAssignmentAnnotation_RunMode_DoesNotCreateGlobalRoleAssignmentForAggregateOwnedTargetWithoutDirectReference() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var storage = builder.AddAzureStorage("storage") + .ClearDefaultRoleAssignments(); + + var aggregate = new TestProvisioningResource("aggregate"); + aggregate.Annotations.Add(new RoleAssignmentAnnotation( + () => storage.Resource, + CreateStorageRoleDefinitions(StorageBuiltInRole.StorageBlobDataReader))); + + builder.AddResource(aggregate); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.DoesNotContain(model.Resources.OfType(), r => r.TargetAzureResource == storage.Resource); + } + [Fact] public async Task PublishDeploymentTargetIncludesComputedPrerequisitesInReferences() { @@ -356,8 +609,12 @@ [.. context.Model.Resources model.Resources .OfType() .OrderBy(resource => resource.Name, StringComparer.Ordinal), - resource => Assert.Single(resource.Annotations.OfType()), - resource => Assert.Single(resource.Annotations.OfType())); + resource => Assert.Single( + resource.Annotations.OfType().SelectMany(annotation => annotation.Resources), + prerequisite => prerequisite.Name == "service-a-roles-signalr"), + resource => Assert.Single( + resource.Annotations.OfType().SelectMany(annotation => annotation.Resources), + prerequisite => prerequisite.Name == "service-b-roles-signalr")); } [Fact] @@ -490,13 +747,15 @@ public async Task AppliesRoleAssignmentsOnlyToDirectReferences() Assert.Collection(model.Resources.Select(r => r.Name), n => Assert.StartsWith("azure", n), n => Assert.Equal("env-acr", n), + n => Assert.Equal("env-acr-pull-identity", n), n => Assert.Equal("env", n), n => Assert.Equal("storage", n), n => Assert.Equal("blobs", n), n => Assert.Equal("api", n), n => Assert.Equal("api2", n), n => Assert.Equal("api-identity", n), - n => Assert.Equal("api-roles-storage", n)); + n => Assert.Equal("api-roles-storage", n), + n => Assert.Equal("env-roles-env-acr", n)); } [Fact] @@ -525,13 +784,15 @@ public async Task ViteAppDoesNotGetManagedIdentity() Assert.Collection(model.Resources.Select(r => r.Name), n => Assert.StartsWith("azure", n), n => Assert.Equal("env-acr", n), + n => Assert.Equal("env-acr-pull-identity", n), n => Assert.Equal("env", n), n => Assert.Equal("storage", n), n => Assert.Equal("blobs", n), n => Assert.Equal("api", n), n => Assert.Equal("frontend", n), n => Assert.Equal("api-identity", n), - n => Assert.Equal("api-roles-storage", n)); + n => Assert.Equal("api-roles-storage", n), + n => Assert.Equal("env-roles-env-acr", n)); // The ViteApp should NOT get a managed identity since it is a BuildOnlyContainer resource, // even though it references the storage account. Only the API should get a managed identity. @@ -753,4 +1014,14 @@ private static Task ExecutePipelineAsync(DistributedApplication app) return pipeline.ExecuteAsync(context); } + + private static HashSet CreateStorageRoleDefinitions(params StorageBuiltInRole[] roles) + { + return [.. roles.Select(role => new RoleDefinition(role.ToString(), StorageBuiltInRole.GetBuiltInRoleName(role)))]; + } + + private static HashSet CreateKeyVaultRoleDefinitions(params KeyVaultBuiltInRole[] roles) + { + return [.. roles.Select(role => new RoleDefinition(role.ToString(), KeyVaultBuiltInRole.GetBuiltInRoleName(role)))]; + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep index d1203024fbc..625836b3b05 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.bicep @@ -5,28 +5,16 @@ param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_pull_identity_outputs_clientid string + +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -51,6 +39,6 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.json index aff9b2f4d4f..9845f68d560 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceEnvironmentWithoutDashboardAddsEnvironmentResource.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep index 643fb1fb539..b7c25e38736 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.bicep @@ -1,32 +1,20 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_pull_identity_outputs_clientid string + +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -64,7 +52,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -104,7 +92,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -156,9 +144,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.json index aff9b2f4d4f..9845f68d560 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsDefaultLocation.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep index e5d783a7680..42b4017a7ad 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.bicep @@ -1,32 +1,20 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_pull_identity_outputs_clientid string + +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -64,7 +52,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -104,7 +92,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -156,9 +144,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.json index aff9b2f4d4f..9845f68d560 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocation.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep index 67c97b129a5..dce6ef4028f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.bicep @@ -1,34 +1,22 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + +param env_acr_pull_identity_outputs_clientid string + param env_acr_outputs_name string param appInsightsLocation string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -66,7 +54,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -106,7 +94,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -158,9 +146,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.json index 43767c55cff..ad461ee9f6e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsLocationParam.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "appInsightsLocation": "{appInsightsLocation.value}", "userPrincipalId": "" diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsNormalizesBicepIdentifiers.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsNormalizesBicepIdentifiers.verified.bicep index 08a87488e0a..095245ca04e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsNormalizesBicepIdentifiers.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithApplicationInsightsNormalizesBicepIdentifiers.verified.bicep @@ -1,32 +1,20 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } -param env_1_acr_outputs_name string +param env_1_acr_pull_identity_outputs_id string -resource env_1_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_1_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_1_acr_pull_identity_outputs_clientid string + +param env_1_acr_outputs_name string resource env_1_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_1_acr_outputs_name } -resource env_1_acr_env_1_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_1_acr.id, env_1_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_1_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_1_acr -} - resource env_1_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('env1asplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -64,7 +52,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_1_mi.properties.clientId + acrUserManagedIdentityID: env_1_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -104,7 +92,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_1_mi.properties.clientId + value: env_1_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -156,9 +144,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_1_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_1_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_1_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_1_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_1_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_1_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_1_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep index d702111eb40..ac53e1b467a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.bicep @@ -1,34 +1,22 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + +param env_acr_pull_identity_outputs_clientid string + param env_acr_outputs_name string param existingappinsights_outputs_name string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -66,7 +54,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -106,7 +94,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -141,9 +129,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.json index 75f212c0623..d18147dd12e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddAppServiceWithExistingApplicationInsights.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "existingappinsights_outputs_name": "{existingAppInsights.outputs.name}", "userPrincipalId": "" diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep index 38fae319461..548e81591fa 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.bicep @@ -1,32 +1,20 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_pull_identity_outputs_clientid string + +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_asplan 'Microsoft.Web/serverfarms@2025-03-01' = { name: take('envasplan-${uniqueString(resourceGroup().id)}', 60) location: location @@ -64,7 +52,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -104,7 +92,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -135,9 +123,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.json index aff9b2f4d4f..9845f68d560 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AddContainerAppEnvironmentAddsEnvironmentResource.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.bicep index 203ecbdf8a5..cafea709c45 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.bicep @@ -5,33 +5,21 @@ param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + +param env_acr_pull_identity_outputs_clientid string + param registryName string param sharedRg string param appServicePlanName string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: registryName scope: resourceGroup(sharedRg) } -resource acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: acr -} - resource env 'Microsoft.Web/serverfarms@2025-03-01' existing = { name: appServicePlanName scope: resourceGroup(sharedRg) @@ -47,6 +35,6 @@ output AZURE_CONTAINER_REGISTRY_NAME string = acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.json index 6f7ce79d856..e35f29c5020 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesEnvWithExistingPlanAndRegistry.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "registryName": "{registryName.value}", "sharedRg": "{sharedRg.value}", "appServicePlanName": "{appServicePlanName.value}", diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#00.verified.bicep index 66244cac743..750054f3150 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#00.verified.bicep @@ -1,4 +1,4 @@ -targetScope = 'subscription' +targetScope = 'subscription' param resourceGroupName string @@ -23,11 +23,21 @@ module env_acr 'env-acr/env-acr.bicep' = { } } +module env_acr_pull_identity 'env-acr-pull-identity/env-acr-pull-identity.bicep' = { + name: 'env-acr-pull-identity' + scope: rg + params: { + location: location + } +} + module env 'env/env.bicep' = { name: 'env' scope: rg params: { location: location + env_acr_pull_identity_outputs_id: env_acr_pull_identity.outputs.id + env_acr_pull_identity_outputs_clientid: env_acr_pull_identity.outputs.clientId env_acr_outputs_name: env_acr.outputs.name appServicePlanName: appServicePlanName appServicePlanResourceGroup: appServicePlanResourceGroup @@ -35,6 +45,16 @@ module env 'env/env.bicep' = { } } +module env_roles_env_acr 'env-roles-env-acr/env-roles-env-acr.bicep' = { + name: 'env-roles-env-acr' + scope: rg + params: { + location: location + env_acr_outputs_name: env_acr.outputs.name + principalId: env_acr_pull_identity.outputs.principalId + } +} + output env_AZURE_CONTAINER_REGISTRY_ENDPOINT string = env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT output env_planId string = env.outputs.planId diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#01.verified.bicep index f7ec9b3e4d8..c76b33da1a6 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanPublishExistingAppServicePlan#01.verified.bicep @@ -5,32 +5,20 @@ param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + +param env_acr_pull_identity_outputs_clientid string + param env_acr_outputs_name string param appServicePlanName string param appServicePlanResourceGroup string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env 'Microsoft.Web/serverfarms@2025-03-01' existing = { name: appServicePlanName scope: resourceGroup(appServicePlanResourceGroup) @@ -59,7 +47,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -99,7 +87,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -130,12 +118,12 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_PRINCIPAL_ID string = env_contributor_mi.properties.principalId -output AZURE_APP_SERVICE_DASHBOARD_URI string = 'https://${take('${toLower('env')}-${toLower('aspiredashboard')}-${uniqueString(resourceGroup().id)}', 60)}.azurewebsites.net' +output AZURE_APP_SERVICE_DASHBOARD_URI string = 'https://${take('${toLower('env')}-${toLower('aspiredashboard')}-${uniqueString(resourceGroup().id)}', 60)}.azurewebsites.net' \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep index d2d885c954c..c76b33da1a6 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.bicep @@ -1,36 +1,24 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + +param env_acr_pull_identity_outputs_clientid string + param env_acr_outputs_name string param appServicePlanName string param appServicePlanResourceGroup string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env 'Microsoft.Web/serverfarms@2025-03-01' existing = { name: appServicePlanName scope: resourceGroup(appServicePlanResourceGroup) @@ -59,7 +47,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { numberOfWorkers: 1 linuxFxVersion: 'ASPIREDASHBOARD|1.0' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: env_mi.properties.clientId + acrUserManagedIdentityID: env_acr_pull_identity_outputs_clientid appSettings: [ { name: 'DASHBOARD__FRONTEND__AUTHMODE' @@ -99,7 +87,7 @@ resource dashboard 'Microsoft.Web/sites@2025-03-01' = { } { name: 'ALLOWED_MANAGED_IDENTITIES' - value: env_mi.properties.clientId + value: env_acr_pull_identity_outputs_clientid } { name: 'ASPIRE_ENVIRONMENT_NAME' @@ -130,9 +118,9 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_mi.properties.clientId +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_CLIENT_ID string = env_acr_pull_identity_outputs_clientid output AZURE_WEBSITE_CONTRIBUTOR_MANAGED_IDENTITY_ID string = env_contributor_mi.id diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.json index 0d492e11f13..8cdbc827c94 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.AzureAppServiceEnvironmentCanReferenceExistingAppServicePlan.verified.json @@ -1,7 +1,9 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", + "env_acr_pull_identity_outputs_clientid": "{env-acr-pull-identity.outputs.clientId}", "env_acr_outputs_name": "{env-acr.outputs.name}", "appServicePlanName": "{appServicePlanName.value}", "appServicePlanResourceGroup": "{appServicePlanResourceGroup.value}", diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json index f444e0a16c3..1d70ef46ebf 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureAppServiceTests.MultipleAzureAppServiceEnvironmentsSupported.verified.json @@ -4,10 +4,16 @@ "type": "azure.bicep.v0", "path": "env1-acr.module.bicep" }, + "env1-acr-pull-identity": { + "type": "azure.bicep.v0", + "path": "env1-acr-pull-identity.module.bicep" + }, "env1": { "type": "azure.bicep.v0", "path": "env1.module.bicep", "params": { + "env1_acr_pull_identity_outputs_id": "{env1-acr-pull-identity.outputs.id}", + "env1_acr_pull_identity_outputs_clientid": "{env1-acr-pull-identity.outputs.clientId}", "env1_acr_outputs_name": "{env1-acr.outputs.name}", "userPrincipalId": "" } @@ -16,10 +22,16 @@ "type": "azure.bicep.v0", "path": "env2-acr.module.bicep" }, + "env2-acr-pull-identity": { + "type": "azure.bicep.v0", + "path": "env2-acr-pull-identity.module.bicep" + }, "env2": { "type": "azure.bicep.v0", "path": "env2.module.bicep", "params": { + "env2_acr_pull_identity_outputs_id": "{env2-acr-pull-identity.outputs.id}", + "env2_acr_pull_identity_outputs_clientid": "{env2-acr-pull-identity.outputs.clientId}", "env2_acr_outputs_name": "{env2-acr.outputs.name}", "userPrincipalId": "" } @@ -97,6 +109,22 @@ "external": true } } + }, + "env1-roles-env1-acr": { + "type": "azure.bicep.v0", + "path": "env1-roles-env1-acr.module.bicep", + "params": { + "env1_acr_outputs_name": "{env1-acr.outputs.name}", + "principalId": "{env1-acr-pull-identity.outputs.principalId}" + } + }, + "env2-roles-env2-acr": { + "type": "azure.bicep.v0", + "path": "env2-roles-env2-acr.module.bicep", + "params": { + "env2_acr_outputs_name": "{env2-acr.outputs.name}", + "principalId": "{env2-acr-pull-identity.outputs.principalId}" + } } } } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.bicep index f9d1b63e9fa..f6dc5ed3662 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' @@ -9,6 +9,8 @@ param environmentName string param sharedRg string +param env_acr_pull_identity_outputs_id string + param env_acr_outputs_name string resource env 'Microsoft.App/managedEnvironments@2025-07-01' existing = { @@ -16,31 +18,15 @@ resource env 'Microsoft.App/managedEnvironments@2025-07-01' existing = { scope: resourceGroup(sharedRg) } -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.json index 9bc677f1a95..96afb6dbeab 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_PublishGeneratesThinModuleReferencingExistingEnvironment.verified.json @@ -1,9 +1,10 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { "environmentName": "{environmentName.value}", "sharedRg": "{sharedRg.value}", + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.bicep index f8677f05beb..2fae42ec270 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' @@ -9,6 +9,8 @@ param environmentName string param sharedRg string +param env_acr_pull_identity_outputs_id string + param registryName string resource env 'Microsoft.App/managedEnvironments@2025-07-01' existing = { @@ -16,35 +18,19 @@ resource env 'Microsoft.App/managedEnvironments@2025-07-01' existing = { scope: resourceGroup(sharedRg) } -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: registryName scope: resourceGroup(sharedRg) } -resource acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: acr -} - output AZURE_CONTAINER_REGISTRY_NAME string = acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id -output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.json index c38700cd900..b2b39a6db0f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.AsExisting_WithExplicitContainerRegistry_PublishGeneratesThinModule.verified.json @@ -1,10 +1,11 @@ -{ +{ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { "environmentName": "{environmentName.value}", "sharedRg": "{sharedRg.value}", + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "registryName": "{registryName.value}", "userPrincipalId": "" } -} \ No newline at end of file +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#00.verified.bicep index 288c8b3680b..c501e9cea48 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#00.verified.bicep @@ -1,4 +1,4 @@ -targetScope = 'subscription' +targetScope = 'subscription' param resourceGroupName string @@ -19,16 +19,35 @@ module acr 'acr/acr.bicep' = { } } +module env_acr_pull_identity 'env-acr-pull-identity/env-acr-pull-identity.bicep' = { + name: 'env-acr-pull-identity' + scope: rg + params: { + location: location + } +} + module env 'env/env.bicep' = { name: 'env' scope: rg params: { location: location + env_acr_pull_identity_outputs_id: env_acr_pull_identity.outputs.id acr_outputs_name: acr.outputs.name userPrincipalId: principalId } } +module env_roles_acr 'env-roles-acr/env-roles-acr.bicep' = { + name: 'env-roles-acr' + scope: rg + params: { + location: location + acr_outputs_name: acr.outputs.name + principalId: env_acr_pull_identity.outputs.principalId + } +} + output env_AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN output env_AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#01.verified.bicep index 0e67ae0d495..237d931f66e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistryIsRedundant#01.verified.bicep @@ -1,32 +1,18 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } -param acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param acr_outputs_name string resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: acr_outputs_name } -resource acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -75,7 +61,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.bicep index dc70c22c517..8fe2a703ab8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.bicep @@ -1,36 +1,22 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } +param app_host_acr_pull_identity_outputs_id string + param app_host_acr_outputs_name string param log_env_shared_name string param log_env_shared_rg string -resource app_host_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('app_host_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource app_host_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: app_host_acr_outputs_name } -resource app_host_acr_app_host_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(app_host_acr.id, app_host_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: app_host_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: app_host_acr -} - resource log_env_shared 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { name: log_env_shared_name scope: resourceGroup(log_env_shared_rg) @@ -73,7 +59,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = app_host_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = app_host_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = app_host_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = app_host_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = app_host.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.json index 0d767c250b8..f9f4db89aab 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithAzureLogAnalyticsWorkspace_RespectsExistingWorkspaceInDifferentResourceGroup.verified.json @@ -1,7 +1,8 @@ -{ +{ "type": "azure.bicep.v0", "path": "app-host.module.bicep", "params": { + "app_host_acr_pull_identity_outputs_id": "{app-host-acr-pull-identity.outputs.id}", "app_host_acr_outputs_name": "{app-host-acr.outputs.name}", "log_env_shared_name": "{log-env-shared-name.value}", "log_env_shared_rg": "{log-env-shared-rg.value}", diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep index 14a03d3f091..3c7c8ee0641 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep @@ -1,34 +1,20 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + param env_acr_outputs_name string param myvnet_outputs_container_apps_subnet_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -80,7 +66,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.bicep index 1b38b2f2fa3..0202831a560 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.bicep @@ -5,28 +5,14 @@ param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -118,7 +104,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.json index aff9b2f4d4f..100dc2898e9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=False.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.bicep index 053997260ea..be204b24cfc 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.bicep @@ -5,30 +5,16 @@ param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + param env_acr_outputs_name string var resourceToken = uniqueString(resourceGroup().id) -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: 'mi-${resourceToken}' - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: replace('acr-${resourceToken}', '-', '') } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: 'law-${resourceToken}' location: location @@ -120,7 +106,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.json index aff9b2f4d4f..100dc2898e9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentAddsEnvironmentResource_useAzdNaming=True.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep index e0ab7783fd4..fdbfa32cef1 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep @@ -5,30 +5,16 @@ param userPrincipalId string = '' param tags object = { } +param my_long_env_name_acr_pull_identity_outputs_id string + param my_long_env_name_acr_outputs_name string var resourceToken = uniqueString(resourceGroup().id) -resource my_long_env_name_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('my_long_env_name_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource my_long_env_name_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: my_long_env_name_acr_outputs_name } -resource my_long_env_name_acr_my_long_env_name_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(my_long_env_name_acr.id, my_long_env_name_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: my_long_env_name_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: my_long_env_name_acr -} - resource my_long_env_name_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('mylongenvnamelaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -120,7 +106,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = my_long_env_name_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_long_env_name_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_long_env_name_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_long_env_name_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_long_env_name.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep index 607feb19989..c92fe6c3ae2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep @@ -5,30 +5,16 @@ param userPrincipalId string = '' param tags object = { } +param my_ace_acr_pull_identity_outputs_id string + param my_ace_acr_outputs_name string var resourceToken = uniqueString(resourceGroup().id) -resource my_ace_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('my_ace_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource my_ace_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: my_ace_acr_outputs_name } -resource my_ace_acr_my_ace_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(my_ace_acr.id, my_ace_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: my_ace_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: my_ace_acr -} - resource my_ace_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('myacelaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -168,7 +154,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = my_ace_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_ace_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_ace.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.bicep index c6eff64aca2..53a47965bae 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.bicep @@ -5,28 +5,14 @@ param userPrincipalId string = '' param tags object = { } -param customregistry_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param customregistry_outputs_name string resource customregistry 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: customregistry_outputs_name } -resource customregistry_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(customregistry.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: customregistry -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -75,7 +61,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = customregistry.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = customregistry.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.json index cc8f2653782..a522841882f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomRegistry#00.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "customregistry_outputs_name": "{customregistry.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.bicep index e7bed670938..b260e31d1f2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.bicep @@ -5,30 +5,16 @@ param userPrincipalId string = '' param tags object = { } +param env_acr_pull_identity_outputs_id string + param env_acr_outputs_name string param customworkspace_outputs_name string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} - resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource customworkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' existing = { name: customworkspace_outputs_name } @@ -70,7 +56,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.json index 0a71a394f85..61e435e289f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithCustomWorkspace#00.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "customworkspace_outputs_name": "{customworkspace.outputs.name}", "userPrincipalId": "" diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.bicep index 49ebfaebad4..0f66c66eb81 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.bicep @@ -5,28 +5,14 @@ param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -67,7 +53,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.json index aff9b2f4d4f..100dc2898e9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardDisabled.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.bicep index 1340440b1f3..0145ba98839 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.bicep @@ -5,28 +5,14 @@ param userPrincipalId string = '' param tags object = { } -param env_acr_outputs_name string +param env_acr_pull_identity_outputs_id string -resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param env_acr_outputs_name string resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: env_acr_outputs_name } -resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: env_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: env_acr -} - resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -75,7 +61,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.json index aff9b2f4d4f..100dc2898e9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ContainerAppEnvironmentWithDashboardEnabled.verified.json @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "env.module.bicep", "params": { + "env_acr_pull_identity_outputs_id": "{env-acr-pull-identity.outputs.id}", "env_acr_outputs_name": "{env-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json index 90767a4c7c5..44339a1a575 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleAzureContainerAppEnvironmentsSupported.verified.json @@ -4,10 +4,15 @@ "type": "azure.bicep.v0", "path": "env1-acr.module.bicep" }, + "env1-acr-pull-identity": { + "type": "azure.bicep.v0", + "path": "env1-acr-pull-identity.module.bicep" + }, "env1": { "type": "azure.bicep.v0", "path": "env1.module.bicep", "params": { + "env1_acr_pull_identity_outputs_id": "{env1-acr-pull-identity.outputs.id}", "env1_acr_outputs_name": "{env1-acr.outputs.name}", "userPrincipalId": "" } @@ -16,10 +21,15 @@ "type": "azure.bicep.v0", "path": "env2-acr.module.bicep" }, + "env2-acr-pull-identity": { + "type": "azure.bicep.v0", + "path": "env2-acr-pull-identity.module.bicep" + }, "env2": { "type": "azure.bicep.v0", "path": "env2.module.bicep", "params": { + "env2_acr_pull_identity_outputs_id": "{env2-acr-pull-identity.outputs.id}", "env2_acr_outputs_name": "{env2-acr.outputs.name}", "userPrincipalId": "" } @@ -47,6 +57,22 @@ "env2_outputs_azure_container_apps_environment_id": "{env2.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}" } } + }, + "env1-roles-env1-acr": { + "type": "azure.bicep.v0", + "path": "env1-roles-env1-acr.module.bicep", + "params": { + "env1_acr_outputs_name": "{env1-acr.outputs.name}", + "principalId": "{env1-acr-pull-identity.outputs.principalId}" + } + }, + "env2-roles-env2-acr": { + "type": "azure.bicep.v0", + "path": "env2-roles-env2-acr.module.bicep", + "params": { + "env2_acr_outputs_name": "{env2-acr.outputs.name}", + "principalId": "{env2-acr-pull-identity.outputs.principalId}" + } } } } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#02.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#02.verified.txt index 5f4e5709703..c399c089eec 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#02.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#02.verified.txt @@ -2,6 +2,7 @@ "type": "azure.bicep.v0", "path": "my-ace.module.bicep", "params": { + "my_ace_acr_pull_identity_outputs_id": "{my-ace-acr-pull-identity.outputs.id}", "my_ace_acr_outputs_name": "{my-ace-acr.outputs.name}", "userPrincipalId": "" } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#03.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#03.verified.txt index d03fabe909a..46f38085838 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#03.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.MultipleVolumesHaveUniqueNamesInBicep#03.verified.txt @@ -5,28 +5,14 @@ param userPrincipalId string = '' param tags object = { } -param my_ace_acr_outputs_name string +param my_ace_acr_pull_identity_outputs_id string -resource my_ace_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { - name: take('my_ace_mi-${uniqueString(resourceGroup().id)}', 128) - location: location - tags: tags -} +param my_ace_acr_outputs_name string resource my_ace_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { name: my_ace_acr_outputs_name } -resource my_ace_acr_my_ace_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(my_ace_acr.id, my_ace_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) - properties: { - principalId: my_ace_mi.properties.principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') - principalType: 'ServicePrincipal' - } - scope: my_ace_acr -} - resource my_ace_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { name: take('myacelaw-${uniqueString(resourceGroup().id)}', 63) location: location @@ -166,7 +152,7 @@ output AZURE_CONTAINER_REGISTRY_NAME string = my_ace_acr.name output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_ace_acr.properties.loginServer -output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_mi.id +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_acr_pull_identity_outputs_id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_ace.name From 23f75ac409e18db1c0f8ae75b95ab76769111a79 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 7 Jun 2026 13:58:55 -0700 Subject: [PATCH 2/9] Add role assignment implementation comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppEnvironmentResource.cs | 6 +++++ .../AzureContainerAppExtensions.cs | 11 ++++++++- .../AzureAppServiceEnvironmentExtensions.cs | 11 ++++++++- .../AzureAppServiceEnvironmentResource.cs | 6 +++++ .../AzureResourcePreparer.cs | 13 +++++++++++ .../RoleAssignmentAnnotation.cs | 23 +++++++++++++++++-- 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 3aa6aa263e4..24562e1867a 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -243,6 +243,9 @@ private IReadOnlySet GetEnvironmentDeploymentPrerequisites() return new HashSet(); } + // The preparer writes environment-owned prerequisites after it materializes generated + // role-assignment modules. Collapse all annotations here so future environment-owned + // infrastructure can participate in the same deployment ordering contract. return prerequisiteAnnotations.SelectMany(a => a.Resources).ToHashSet(); } @@ -256,6 +259,9 @@ private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet< var newPrerequisites = prerequisites.ToHashSet(); if (resource.TryGetAnnotationsOfType(out var existingAnnotations)) { + // A resource can already have prerequisites from direct Azure references. Only add the + // environment-level resources it does not already wait on to avoid duplicate References + // when deployment targets are generated. newPrerequisites.ExceptWith(existingAnnotations.SelectMany(a => a.Resources)); } diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index d6f1177c564..a5fbdfb13ea 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -417,7 +417,9 @@ public static IResourceBuilder AddAzureCon // identity must exist in the model before the module Bicep is generated. The AcrPull role // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance - // to replace the default registry. + // to replace the default registry. Keeping the role assignment outside the environment module + // is what allows existing registries in another resource group to work with Bicep's extension + // resource scope rules. var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); containerAppEnvResource.Annotations.Add(new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); containerAppEnvResource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); @@ -750,6 +752,9 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder() .Where(a => a.IdentityResource == identityAnnotation.Identity) .ToArray()) @@ -768,6 +773,8 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder(out var identityAnnotation) || !identityAnnotation.AssignAcrPullRole) { + // A replacement annotation from WithAcrPullIdentity is the opt-out signal. Returning null + // lets the deferred RoleAssignmentAnnotation remain in the model without generating RBAC. return null; } @@ -795,6 +802,8 @@ private static AzureProvisioningResource GetContainerRegistryForAcrPullRole(Azur private static AzureUserAssignedIdentityResource CreateDefaultAcrPullIdentity(IDistributedApplicationBuilder builder, string environmentName) { var identity = new AzureUserAssignedIdentityResource(GetUniqueAcrPullIdentityName(builder, environmentName)); + // The identity is a first-class resource so the preparer can order it before the environment + // module and pass its id into the environment Bicep as an input parameter. builder.AddResource(identity); return identity; diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs index c311d2899bc..5b8effdcb30 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentExtensions.cs @@ -280,7 +280,9 @@ public static IResourceBuilder AddAzureAppSe // identity must exist in the model before the module Bicep is generated. The AcrPull role // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance - // to replace the default registry. + // to replace the default registry. Keeping the role assignment outside the environment module + // is what allows existing registries in another resource group to work with Bicep's extension + // resource scope rules. var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); resource.Annotations.Add(new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); resource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); @@ -545,6 +547,9 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder() .Where(a => a.IdentityResource == identityAnnotation.Identity) .ToArray()) @@ -563,6 +568,8 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder(out var identityAnnotation) || !identityAnnotation.AssignAcrPullRole) { + // A replacement annotation from WithAcrPullIdentity is the opt-out signal. Returning null + // lets the deferred RoleAssignmentAnnotation remain in the model without generating RBAC. return null; } @@ -590,6 +597,8 @@ private static AzureProvisioningResource GetContainerRegistryForAcrPullRole(Azur private static AzureUserAssignedIdentityResource CreateDefaultAcrPullIdentity(IDistributedApplicationBuilder builder, string environmentName) { var identity = new AzureUserAssignedIdentityResource(GetUniqueAcrPullIdentityName(builder, environmentName)); + // The identity is a first-class resource so the preparer can order it before the environment + // module and pass its id/client id into the environment Bicep as input parameters. builder.AddResource(identity); return identity; diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index 515827abf29..a96c469213d 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -250,6 +250,9 @@ private IReadOnlySet GetEnvironmentDeploymentPrerequisites() return new HashSet(); } + // The preparer writes environment-owned prerequisites after it materializes generated + // role-assignment modules. Collapse all annotations here so future environment-owned + // infrastructure can participate in the same deployment ordering contract. return prerequisiteAnnotations.SelectMany(a => a.Resources).ToHashSet(); } @@ -263,6 +266,9 @@ private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet< var newPrerequisites = prerequisites.ToHashSet(); if (resource.TryGetAnnotationsOfType(out var existingAnnotations)) { + // A resource can already have prerequisites from direct Azure references. Only add the + // environment-level resources it does not already wait on to avoid duplicate References + // when deployment targets are generated. newPrerequisites.ExceptWith(existingAnnotations.SelectMany(a => a.Resources)); } diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index ed003441ded..7c2fbb4d1b0 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -112,6 +112,10 @@ private async Task BuildRoleAssignmentAnnotations( { var globalRoleAssignments = new Dictionary>(); + // The same annotations drive two different outputs. In Run mode, there is no published + // workload identity to attach, so role assignments target the deployment principal globally. + // In Publish mode, each owner gets (or supplies) a user-assigned identity plus targeted role + // modules, and those modules become deployment prerequisites for the owner. var provisioningResources = azureResources.Select(r => r.AzureResource).OfType().ToArray(); if (!supportsTargetedRoleAssignments) { @@ -274,6 +278,9 @@ private void CreatePublishRoleAssignmentResources( if (resource != identityResource) { + // Publishers discover workload identities from AppIdentityAnnotation, not by scanning the + // role-assignment modules. Keep the identity in the model so generated deployment targets + // can attach it and expose any required client/id parameters. EnsureIdentityResource(appModel, resource, identityResource); } @@ -399,6 +406,9 @@ private List CreateRoleAssignmentsResources( var roleAssignmentResources = new List(); foreach (var (targetResource, roles) in roleAssignments) { + // Keep targeted role assignments in their own Bicep module instead of inlining them + // under the owner module. Existing Azure resources can live in a different resource + // group, and Bicep extension resources must be emitted at the scope they target. var roleAssignmentResource = new AzureRoleAssignmentResource( $"{resource.Name}-roles-{targetResource.Name}", targetResource, @@ -428,6 +438,9 @@ private void AddRoleAssignmentsInfrastructure( IEnumerable roles, AzureUserAssignedIdentityResource appIdentityResource) { + // Role assignment builders only evaluate the principal values they need. Keep them lazy so + // resources that do not emit a particular field do not unnecessarily create matching Bicep + // parameters on the role-assignment module. var context = new AddRoleAssignmentsContext( infra, executionContext, diff --git a/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs b/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs index 483b1a5c9f5..c4570d74274 100644 --- a/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs +++ b/src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs @@ -10,7 +10,14 @@ namespace Aspire.Hosting.Azure; /// Specifies the roles that the current resource should be assigned to the target Azure resource. /// /// -/// This annotation is applied to compute resources (e.g., Projects or Containers) that need to interact with Azure resources. +/// +/// This annotation is most commonly applied to compute resources (for example, projects or containers) that need to +/// interact with Azure resources. +/// +/// +/// Aggregate resources can also use this annotation when they own internal Azure resources and need the Azure +/// preparer to create role-assignment infrastructure on behalf of those internals. +/// /// public class RoleAssignmentAnnotation : IResourceAnnotation { @@ -38,8 +45,16 @@ public RoleAssignmentAnnotation(AzureProvisioningResource target, IReadOnlySetResolves the Azure resource that the current resource will interact with, or to skip the role assignment. /// The roles that the current resource should be assigned to the resolved target. /// + /// /// Use this overload when a resource owns role assignments for internal Azure resources whose final target can be - /// changed by later builder calls. + /// changed by later builder calls. For example, a compute environment can create its ACR-pull role annotation + /// before a later WithAzureContainerRegistry call swaps the default registry for an explicit registry. + /// + /// + /// Returning intentionally skips materialization. The first non-null target is cached so + /// later grouping and de-duplication see a stable target, while early optional probes do not permanently suppress + /// a target that resolves later. + /// /// public RoleAssignmentAnnotation(Func targetResolver, IReadOnlySet roles) { @@ -53,6 +68,10 @@ public RoleAssignmentAnnotation(Func targetResolver, /// /// The Azure resource that the current resource will interact with. /// + /// + /// Deferred annotations can intentionally resolve to while the app model is still being + /// mutated. Accessing this property requires a concrete target and throws when no target can be resolved. + /// public AzureProvisioningResource Target => TryGetTarget(out var target) ? target From 3bf44a64179a4f9b1a4a5ebea42e555275b3d935 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 7 Jun 2026 14:10:33 -0700 Subject: [PATCH 3/9] Clarify deferred registry role comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppExtensions.cs | 10 ++++++---- .../AzureAppServiceEnvironmentExtensions.cs | 10 ++++++---- src/Aspire.Hosting.Azure/RoleAssignmentAnnotation.cs | 4 +++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index a5fbdfb13ea..7172c385374 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -417,9 +417,10 @@ public static IResourceBuilder AddAzureCon // identity must exist in the model before the module Bicep is generated. The AcrPull role // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance - // to replace the default registry. Keeping the role assignment outside the environment module - // is what allows existing registries in another resource group to work with Bicep's extension - // resource scope rules. + // to replace the default registry. Resolving the target here would grant AcrPull on the + // default registry even when the environment later points at a caller-supplied registry. + // Keeping the role assignment outside the environment module is what allows existing + // registries in another resource group to work with Bicep's extension resource scope rules. var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); containerAppEnvResource.Annotations.Add(new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); containerAppEnvResource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); @@ -779,7 +780,8 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder AddAzureAppSe // identity must exist in the model before the module Bicep is generated. The AcrPull role // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance - // to replace the default registry. Keeping the role assignment outside the environment module - // is what allows existing registries in another resource group to work with Bicep's extension - // resource scope rules. + // to replace the default registry. Resolving the target here would grant AcrPull on the + // default registry even when the environment later points at a caller-supplied registry. + // Keeping the role assignment outside the environment module is what allows existing + // registries in another resource group to work with Bicep's extension resource scope rules. var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); resource.Annotations.Add(new AzureAppServiceEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); resource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); @@ -574,7 +575,8 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder /// Use this overload when a resource owns role assignments for internal Azure resources whose final target can be /// changed by later builder calls. For example, a compute environment can create its ACR-pull role annotation - /// before a later WithAzureContainerRegistry call swaps the default registry for an explicit registry. + /// before a later WithAzureContainerRegistry call swaps the default registry for an explicit registry; + /// resolving the target immediately would grant AcrPull on the default registry instead of the registry + /// that will actually be used for image pulls. /// /// /// Returning intentionally skips materialization. The first non-null target is cached so From 8ada9ce23ab1e6e4688dc162407638f6cd570846 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 7 Jun 2026 14:12:57 -0700 Subject: [PATCH 4/9] Clarify environment prerequisite ordering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureContainerAppEnvironmentResource.cs | 9 +++++---- .../AzureAppServiceEnvironmentResource.cs | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 24562e1867a..9cfe0534ee8 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -200,10 +200,11 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) services); // Deployment prerequisites such as the AcrPull role assignment are owned by the environment, - // not by each generated container app. - // AzureBicepResource.References is the infrastructure dependency list used for module ordering; - // add them here and to every deployment target so image push/deploy steps wait for the - // environment-level infrastructure they need. + // not by each generated container app. The role-assignment module does not produce a value that + // the environment or container app Bicep naturally references, so there is no implicit data-flow + // dependency for Bicep to infer. References carries the explicit ordering edge: the environment + // module and each generated deployment target must wait for the environment-owned prerequisite + // modules before image push/deploy steps can rely on the permission being present. var environmentDeploymentPrerequisites = GetEnvironmentDeploymentPrerequisites(); foreach (var prerequisite in environmentDeploymentPrerequisites) { diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index a96c469213d..29c83591346 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -201,10 +201,11 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) services); // Deployment prerequisites such as the AcrPull role assignment are owned by the environment, - // not by each generated website. - // AzureBicepResource.References is the infrastructure dependency list used for module ordering; - // add them here and to every deployment target so image push/deploy steps wait for the - // environment-level infrastructure they need. + // not by each generated website. The role-assignment module does not produce a value that the + // environment or website Bicep naturally references, so there is no implicit data-flow dependency + // for Bicep to infer. References carries the explicit ordering edge: the environment module and + // each generated deployment target must wait for the environment-owned prerequisite modules before + // image push/deploy steps can rely on the permission being present. var environmentDeploymentPrerequisites = GetEnvironmentDeploymentPrerequisites(); foreach (var prerequisite in environmentDeploymentPrerequisites) { From 17429bbe9268cfa3ad40a1aecafc307c1d8770a0 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 7 Jun 2026 15:07:05 -0700 Subject: [PATCH 5/9] Use registry annotations for ACR pull roles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Aspire.Hosting.Azure.AppContainers.csproj | 1 + .../AzureContainerAppEnvironmentResource.cs | 32 +++-- .../AzureContainerAppExtensions.cs | 74 +++--------- .../Aspire.Hosting.Azure.AppService.csproj | 1 + .../AzureAppServiceEnvironmentExtensions.cs | 62 +++------- .../AzureAppServiceEnvironmentResource.cs | 32 +++-- .../Aspire.Hosting.Azure.Kubernetes.csproj | 1 + .../AzureKubernetesEnvironmentExtensions.cs | 22 ++-- .../AzureKubernetesEnvironmentResource.cs | 10 +- .../AzureResourcePreparer.cs | 97 +++++++++++---- ...ntainerRegistryRoleAssignmentAnnotation.cs | 20 ++++ .../RoleAssignmentAnnotation.cs | 65 +--------- .../GeneratedContainerRegistryAnnotation.cs | 14 +++ ...ureKubernetesEnvironmentExtensionsTests.cs | 12 +- .../AzureResourcePreparerTests.cs | 113 ++---------------- ...shExistingAppServicePlan#00.verified.bicep | 2 +- ...ServiceEnvironmentsSupported.verified.json | 2 +- ...faultRegistryIsRedundant#00.verified.bicep | 2 +- 18 files changed, 233 insertions(+), 329 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/ContainerRegistryRoleAssignmentAnnotation.cs create mode 100644 src/Shared/GeneratedContainerRegistryAnnotation.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 92848c8485c..f2e1c473d5e 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj +++ b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 9cfe0534ee8..54c1059c172 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -182,13 +182,7 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) return; } - // Remove the default container registry from the model if an explicit registry is configured - if (this.HasAnnotationOfType() && - DefaultContainerRegistry is not null) - { - appModel.Resources.Remove(DefaultContainerRegistry); - DefaultContainerRegistry = null; - } + RemoveGeneratedContainerRegistryIfReplaced(appModel); var logger = services.GetRequiredService>(); var options = services.GetRequiredService>(); @@ -321,10 +315,8 @@ private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet< internal Dictionary VolumeNames { get; } = []; /// - /// Gets the default container registry for this environment. + /// Gets the configured container registry for this environment. /// - internal AzureContainerRegistryResource? DefaultContainerRegistry { get; set; } - ReferenceExpression IContainerRegistry.Name => GetContainerRegistry()?.Name ?? ReferenceExpression.Create($"{ContainerRegistryName}"); ReferenceExpression IContainerRegistry.Endpoint => GetContainerRegistry()?.Endpoint ?? ReferenceExpression.Create($"{ContainerRegistryUrl}"); @@ -339,8 +331,24 @@ private static void AddDeploymentPrerequisites(IResource resource, IReadOnlySet< return annotation.Registry; } - // Fall back to default container registry - return DefaultContainerRegistry; + return null; + } + + private void RemoveGeneratedContainerRegistryIfReplaced(DistributedApplicationModel appModel) + { + if (!this.TryGetLastAnnotation(out var generatedRegistryAnnotation)) + { + return; + } + + if (this.TryGetLastAnnotation(out var currentRegistryAnnotation) && + ReferenceEquals(currentRegistryAnnotation.Registry, generatedRegistryAnnotation.Registry)) + { + return; + } + + appModel.Resources.Remove(generatedRegistryAnnotation.Registry); + Annotations.Remove(generatedRegistryAnnotation); } /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 7172c385374..00cc9286de2 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -154,23 +154,7 @@ public static IResourceBuilder AddAzureCon managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); - AzureProvisioningResource? registry = null; - if (appEnvResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && - registryReferenceAnnotation.Registry is AzureProvisioningResource explicitRegistry) - { - registry = explicitRegistry; - } - else if (appEnvResource.DefaultContainerRegistry is not null) - { - registry = appEnvResource.DefaultContainerRegistry; - } - - if (registry is null) - { - throw new InvalidOperationException($"No container registry associated with environment '{appEnvResource.Name}'. This should have been added automatically."); - } - - var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + var containerRegistry = (ContainerRegistryService)GetContainerRegistryForAcrPullRole(appEnvResource).AddAsExistingResource(infra); infra.Add(containerRegistry); OperationalInsightsWorkspace? laWorkspace = null; @@ -410,21 +394,21 @@ public static IResourceBuilder AddAzureCon // Create the default container registry resource before creating the environment var registryName = $"{name}-acr"; var defaultRegistry = CreateDefaultAzureContainerRegistry(builder, registryName, containerAppEnvResource); - containerAppEnvResource.DefaultContainerRegistry = defaultRegistry; + containerAppEnvResource.Annotations.Add(new ContainerRegistryReferenceAnnotation(defaultRegistry)); + containerAppEnvResource.Annotations.Add(new GeneratedContainerRegistryAnnotation(defaultRegistry)); if (builder.ExecutionContext.IsPublishMode) { // The environment module needs the ACR-pull identity id as an input parameter, so the // identity must exist in the model before the module Bicep is generated. The AcrPull role - // itself is declared as a deferred RoleAssignmentAnnotation so the preparer can emit it as a - // correctly scoped role-assignment module after WithAzureContainerRegistry has had a chance - // to replace the default registry. Resolving the target here would grant AcrPull on the - // default registry even when the environment later points at a caller-supplied registry. - // Keeping the role assignment outside the environment module is what allows existing - // registries in another resource group to work with Bicep's extension resource scope rules. + // itself is declared against the environment's current container registry so the preparer + // resolves the last ContainerRegistryReferenceAnnotation after WithAzureContainerRegistry has + // had a chance to replace the default registry. Keeping the role assignment outside the + // environment module is what allows existing registries in another resource group to work + // with Bicep's extension resource scope rules. var acrPullIdentity = CreateDefaultAcrPullIdentity(builder, name); containerAppEnvResource.Annotations.Add(new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(acrPullIdentity, assignAcrPullRole: true)); containerAppEnvResource.Annotations.Add(new AppIdentityAnnotation(acrPullIdentity)); - containerAppEnvResource.Annotations.Add(new RoleAssignmentAnnotation(() => GetContainerRegistryForGeneratedAcrPullRole(containerAppEnvResource), s_acrPullRole)); + containerAppEnvResource.Annotations.Add(new ContainerRegistryRoleAssignmentAnnotation(s_acrPullRole)); } // Create the resource builder first, then attach the registry to avoid recreating builders @@ -512,23 +496,7 @@ private static void ConfigureExistingContainerAppEnvironmentInfrastructure( managedIdentityIdOutputValue = identityAnnotation.Identity.Id.AsProvisioningParameter(infra); - AzureProvisioningResource? registry = null; - if (appEnvResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && - registryReferenceAnnotation.Registry is AzureProvisioningResource explicitRegistry) - { - registry = explicitRegistry; - } - else if (appEnvResource.DefaultContainerRegistry is not null) - { - registry = appEnvResource.DefaultContainerRegistry; - } - - if (registry is null) - { - throw new InvalidOperationException($"No container registry associated with environment '{appEnvResource.Name}'. This should have been added automatically."); - } - - var containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + var containerRegistry = (ContainerRegistryService)GetContainerRegistryForAcrPullRole(appEnvResource).AddAsExistingResource(infra); infra.Add(containerRegistry); AddSharedContainerAppEnvironmentOutputs(infra, containerRegistry, containerAppEnvironment, managedIdentityIdOutputValue); @@ -738,6 +706,7 @@ public static IResourceBuilder WithAcrPull ArgumentNullException.ThrowIfNull(identityBuilder); RemoveGeneratedAcrPullIdentity(builder); + RemoveGeneratedAcrPullRoleAssignment(builder.Resource); builder.WithAnnotation( new AzureContainerAppEnvironmentAcrPullIdentityAnnotation(identityBuilder.Resource, assignAcrPullRole: false), ResourceAnnotationMutationBehavior.Replace); @@ -764,25 +733,17 @@ private static void RemoveGeneratedAcrPullIdentity(IResourceBuilder