diff --git a/Aspire.sln b/Aspire.sln index fe5408bd274..1d86aaf65e5 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -675,6 +675,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.EntityF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Components.Common.Tests", "tests\Aspire.Components.Common.Tests\Aspire.Components.Common.Tests.csproj", "{30950CEB-2232-F9FC-04FF-ADDCB8AC30A7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.ContainerRegistry", "src\Aspire.Hosting.Azure.ContainerRegistry\Aspire.Hosting.Azure.ContainerRegistry.csproj", "{6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3961,6 +3965,18 @@ Global {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7}.Release|x64.Build.0 = Release|Any CPU {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7}.Release|x86.ActiveCfg = Release|Any CPU {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7}.Release|x86.Build.0 = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|x64.Build.0 = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Debug|x86.Build.0 = Debug|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|Any CPU.Build.0 = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x64.ActiveCfg = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x64.Build.0 = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.ActiveCfg = Release|Any CPU + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4285,6 +4301,7 @@ Global {192747A2-9338-DECF-5C8C-28EB8E13829B} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {8FCA0CFA-7823-6A2F-342A-107A994915B0} = {C424395C-1235-41A4-BF55-07880A04368C} {30950CEB-2232-F9FC-04FF-ADDCB8AC30A7} = {C424395C-1235-41A4-BF55-07880A04368C} + {6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4} diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/api-roles-account-kv.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/api-roles-account-kv.module.bicep index b820675fb54..64c80f3913d 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/api-roles-account-kv.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/api-roles-account-kv.module.bicep @@ -9,11 +9,11 @@ resource account_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: account_kv_outputs_name } -resource account_kv_KeyVaultAdministrator 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(account_kv.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483')) +resource account_kv_KeyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(account_kv.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')) properties: { principalId: principalId - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') principalType: 'ServicePrincipal' } scope: account_kv 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 e91cd7848a8..85c1808b635 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj +++ b/src/Aspire.Hosting.Azure.AppContainers/Aspire.Hosting.Azure.AppContainers.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 9efff7d0211..50e0b2fc4a0 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -98,12 +98,19 @@ public static IResourceBuilder AddAzureCon infra.Add(identity); - var containerRegistry = new ContainerRegistryService(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_acr")) + ContainerRegistryService? containerRegistry = null; + if (appEnvResource.TryGetLastAnnotation(out var registryReferenceAnnotation) && registryReferenceAnnotation.Registry is AzureProvisioningResource registry) { - Sku = new() { Name = ContainerRegistrySkuName.Basic }, - Tags = tags - }; - + containerRegistry = (ContainerRegistryService)registry.AddAsExistingResource(infra); + } + else + { + containerRegistry = new ContainerRegistryService(Infrastructure.NormalizeBicepIdentifier($"{appEnvResource.Name}_acr")) + { + Sku = new() { Name = ContainerRegistrySkuName.Basic }, + Tags = tags + }; + } infra.Add(containerRegistry); var pullRa = containerRegistry.CreateRoleAssignment(ContainerRegistryBuiltInRole.AcrPull, identity); @@ -345,7 +352,7 @@ public static IResourceBuilder AddAzureCon /// /// /// By default, the container app environment resources use a different naming convention than azd. - /// + /// /// This method allows for reusing the previously deployed resources if the application was deployed using /// azd without calling /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs index 588f58fd06e..c452864c155 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -62,8 +64,8 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell #pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. r.Annotations.Add(new DeploymentTargetAnnotation(containerApp) { - ContainerRegistryInfo = caes.FirstOrDefault(), - ComputeEnvironment = environment as IComputeEnvironmentResource // will be null if azd + ContainerRegistry = caes.FirstOrDefault(), + ComputeEnvironment = environment as IComputeEnvironmentResource }); #pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/Aspire.Hosting.Azure.ContainerRegistry.csproj b/src/Aspire.Hosting.Azure.ContainerRegistry/Aspire.Hosting.Azure.ContainerRegistry.csproj new file mode 100644 index 00000000000..8b2a84f7905 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/Aspire.Hosting.Azure.ContainerRegistry.csproj @@ -0,0 +1,23 @@ + + + + $(DefaultTargetFramework) + true + aspire integration hosting azure containerregistry + Azure Container Registry resource types for .NET Aspire. + false + true + + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs new file mode 100644 index 00000000000..a5eb8f5c0cc --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryExtensions.cs @@ -0,0 +1,103 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Azure.ContainerRegistry; +using Azure.Provisioning; +using Azure.Provisioning.ContainerRegistry; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Container Registry resources to the application model. +/// +public static class AzureContainerRegistryExtensions +{ + /// + /// Adds an Azure Container Registry resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the resource. + /// A reference to the builder. + /// Thrown when is null. + /// Thrown when is null or empty. + public static IResourceBuilder AddAzureContainerRegistry(this IDistributedApplicationBuilder builder, [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => + { + var registry = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, + (identifier, name) => + { + var resource = ContainerRegistryService.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => new ContainerRegistryService(infrastructure.AspireResource.GetBicepIdentifier()) + { + Sku = new() { Name = ContainerRegistrySkuName.Basic }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }); + + infrastructure.Add(registry); + + infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = registry.Name }); + infrastructure.Add(new ProvisioningOutput("loginServer", typeof(string)) { Value = registry.LoginServer }); + }; + + var resource = new AzureContainerRegistryResource(name, configureInfrastructure); + + // Don't add the resource to the infrastructure if we're in run mode. + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource) + .WithAnnotation(new DefaultRoleAssignmentsAnnotation(new HashSet())); + } + + /// + /// Configures a resource that implements to use the specified Azure Container Registry. + /// + /// The resource type that implements . + /// The resource builder for a resource that implements . + /// The resource builder for the to use. + /// A reference to the . + /// Thrown when or is null. + public static IResourceBuilder WithAzureContainerRegistry(this IResourceBuilder builder, IResourceBuilder registryBuilder) + where T : IResource, IComputeEnvironmentResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(registryBuilder); + + // Add a ContainerRegistryReferenceAnnotation to indicate that the resource is using a specific registry + builder.WithAnnotation(new ContainerRegistryReferenceAnnotation(registryBuilder.Resource)); + + return builder; + } + + /// + /// Adds role assignments to the specified Azure Container Registry resource. + /// + /// The type of the resource being configured. + /// The resource builder for the resource that will have role assignments. + /// The target Azure Container Registry resource. + /// The roles to assign to the resource. + /// A reference to the . + public static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params ContainerRegistryBuiltInRole[] roles) + where T : IResource + { + return builder.WithRoleAssignments(target, ContainerRegistryBuiltInRole.GetBuiltInRoleName, roles); + } +} diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs new file mode 100644 index 00000000000..07420d94052 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/AzureContainerRegistryResource.cs @@ -0,0 +1,42 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning.ContainerRegistry; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure.ContainerRegistry; + +/// +/// Represents an Azure Container Registry resource. +/// +public class AzureContainerRegistryResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure), IContainerRegistry +{ + /// + /// The name of the Azure Container Registry. + /// + public BicepOutputReference RegistryName => new("name", this); + + /// + /// The endpoint of the Azure Container Registry. + /// + public BicepOutputReference RegistryEndpoint => new("loginServer", this); + + /// + ReferenceExpression IContainerRegistry.Name => ReferenceExpression.Create($"{RegistryName}"); + + /// + ReferenceExpression IContainerRegistry.Endpoint => ReferenceExpression.Create($"{RegistryEndpoint}"); + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var store = ContainerRegistryService.FromExisting(this.GetBicepIdentifier()); + store.Name = RegistryName.AsProvisioningParameter(infra); + infra.Add(store); + return store; + } +} diff --git a/src/Aspire.Hosting.Azure.ContainerRegistry/ContainerRegistryReferenceAnnotation.cs b/src/Aspire.Hosting.Azure.ContainerRegistry/ContainerRegistryReferenceAnnotation.cs new file mode 100644 index 00000000000..e00152b9b62 --- /dev/null +++ b/src/Aspire.Hosting.Azure.ContainerRegistry/ContainerRegistryReferenceAnnotation.cs @@ -0,0 +1,23 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Annotation that indicates a resource is using a specific container registry. +/// +/// +/// Initializes a new instance of the class. +/// +/// The container registry resource. +public class ContainerRegistryReferenceAnnotation(IContainerRegistry registry) : IResourceAnnotation +{ + /// + /// Gets the container registry resource. + /// + public IContainerRegistry Registry { get; } = registry; +} diff --git a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs index 4114b545999..82080304073 100644 --- a/src/Aspire.Hosting.Azure/AzurePublishingContext.cs +++ b/src/Aspire.Hosting.Azure/AzurePublishingContext.cs @@ -238,10 +238,10 @@ void CaptureBicepOutputs(object value) File.Copy(file.Path, modulePath, true); // Capture any bicep outputs from the registry info as it may be needed - Visit(annotation.ContainerRegistryInfo?.Name, CaptureBicepOutputs); - Visit(annotation.ContainerRegistryInfo?.Endpoint, CaptureBicepOutputs); + Visit(annotation.ContainerRegistry?.Name, CaptureBicepOutputs); + Visit(annotation.ContainerRegistry?.Endpoint, CaptureBicepOutputs); - if (annotation.ContainerRegistryInfo is IAzureContainerRegistry acr) + if (annotation.ContainerRegistry is IAzureContainerRegistry acr) { Visit(acr.ManagedIdentityId, CaptureBicepOutputs); } diff --git a/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs index 1e937c36a51..05d19d16198 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeInfrastructure.cs @@ -59,7 +59,7 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell #pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource) { - ComputeEnvironment = environment, + ComputeEnvironment = environment }); #pragma warning restore ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesInfrastructure.cs b/src/Aspire.Hosting.Kubernetes/KubernetesInfrastructure.cs index 98f0523057e..e1d418916f7 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesInfrastructure.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesInfrastructure.cs @@ -59,7 +59,7 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell #pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource) { - ComputeEnvironment = environment, + ComputeEnvironment = environment }); #pragma warning restore ASPIRECOMPUTE001 } diff --git a/src/Aspire.Hosting/ApplicationModel/ContainerResource.cs b/src/Aspire.Hosting/ApplicationModel/ContainerResource.cs index f546b0b1c30..a3c03df7f4a 100644 --- a/src/Aspire.Hosting/ApplicationModel/ContainerResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ContainerResource.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -10,9 +12,7 @@ namespace Aspire.Hosting.ApplicationModel; /// An optional container entrypoint. public class ContainerResource(string name, string? entrypoint = null) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithEndpoints, IResourceWithWaitSupport, -#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IComputeResource -#pragma warning restore ASPIRECOMPUTE001 { /// /// The container Entrypoint. diff --git a/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs index 83878029af9..ecd130e1098 100644 --- a/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/DeploymentTargetAnnotation.cs @@ -20,7 +20,7 @@ public sealed class DeploymentTargetAnnotation(IResource target) : IResourceAnno /// the deployment target, if the deployment target is an image-based environment. /// [Experimental("ASPIRECOMPUTE001")] - public IContainerRegistry? ContainerRegistryInfo { get; set; } + public IContainerRegistry? ContainerRegistry { get; set; } /// /// Gets or sets the compute environment resource associated with the deployment target. diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableResource.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableResource.cs index 3b25c775762..560686db5fd 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutableResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutableResource.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -14,9 +16,7 @@ namespace Aspire.Hosting.ApplicationModel; /// The working directory of the executable. Can be empty. public class ExecutableResource(string name, string command, string workingDirectory) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithEndpoints, IResourceWithWaitSupport, -#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IComputeResource -#pragma warning restore ASPIRECOMPUTE001 { /// /// Gets the command associated with this executable resource. diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index a44eb93c317..ea7a7302d86 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -1,3 +1,5 @@ +#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -9,9 +11,7 @@ namespace Aspire.Hosting.ApplicationModel; /// The name of the resource. public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithServiceDiscovery, IResourceWithWaitSupport, -#pragma warning disable ASPIRECOMPUTE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. IComputeResource -#pragma warning restore ASPIRECOMPUTE001 { // Keep track of the config host for each Kestrel endpoint annotation internal Dictionary KestrelEndpointAnnotationHosts { get; } = new(); @@ -34,7 +34,7 @@ internal bool ShouldInjectEndpointEnvironment(EndpointReference e) // If any filter rejects the endpoint, skip it return !Annotations.OfType() - .Select(a => a.Filter) - .Any(f => !f(endpoint)); + .Select(a => a.Filter) + .Any(f => !f(endpoint)); } } diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index 68985c74ad7..c72eaad79ad 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -411,7 +411,7 @@ public virtual async Task RunAsync(CancellationToken cancellationToken = default { await ExecuteBeforeStartHooksAsync(cancellationToken).ConfigureAwait(false); } - + await _host.RunAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index a8524401996..5615e5c795f 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1923,7 +1923,7 @@ public static IResourceBuilder WithParentRelationship( /// /// var builder = DistributedApplication.CreateBuilder(args); /// var backend = builder.AddProject<Projects.Backend>("backend"); - /// + /// /// var frontend = builder.AddProject<Projects.Manager>("frontend") /// .WithParentRelationship(backend.Resource); /// diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index 291bd8eca75..e3b52ef4a38 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index dddcb7dae61..6d23ab7affc 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -7,6 +7,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppContainers; +using Aspire.Hosting.Azure.ContainerRegistry; using Aspire.Hosting.Utils; using Azure.Provisioning; using Azure.Provisioning.AppContainers; @@ -68,11 +69,11 @@ public async Task AddContainerAppsInfrastructureAddsDeploymentTargetWithContaine """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param outputs_azure_container_apps_environment_default_domain string param outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -152,17 +153,17 @@ public async Task AddDockerfileWithAppsInfrastructureAddsDeploymentTargetWithCon """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -217,11 +218,12 @@ public async Task AddContainerAppEnvironmentAddsDeploymentTargetWithContainerApp var model = app.Services.GetRequiredService(); - var container = Assert.Single(model.GetProjectResources()); + var container = Assert.IsType(Assert.Single(model.GetProjectResources()), exactMatch: false); var target = container.GetDeploymentTargetAnnotation(); - Assert.Same(env.Resource, target?.ComputeEnvironment); + Assert.NotNull(target); + Assert.Same(env.Resource, target.ComputeEnvironment); var resource = target?.DeploymentTarget as AzureProvisioningResource; Assert.NotNull(resource); @@ -356,13 +358,14 @@ public async Task AddExecutableResourceWithPublishAsDockerFileWithAppsInfrastruc var model = app.Services.GetRequiredService(); - var container = Assert.Single(model.GetContainerResources()); + var container = Assert.IsType(Assert.Single(model.GetContainerResources()), exactMatch: false); var target = container.GetDeploymentTargetAnnotation(); - Assert.Same(infra.Resource, target?.ComputeEnvironment); + Assert.NotNull(target); + Assert.Same(infra.Resource, target.ComputeEnvironment); - var resource = target?.DeploymentTarget as AzureProvisioningResource; + var resource = target.DeploymentTarget as AzureProvisioningResource; Assert.NotNull(resource); var (manifest, bicep) = await GetManifestWithBicep(resource); @@ -391,19 +394,19 @@ public async Task AddExecutableResourceWithPublishAsDockerFileWithAppsInfrastruc """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param infra_outputs_azure_container_apps_environment_default_domain string param infra_outputs_azure_container_apps_environment_id string - + param infra_outputs_azure_container_registry_endpoint string - + param infra_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + param env string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -464,11 +467,12 @@ public async Task CanTweakContainerAppEnvironmentUsingPublishAsContainerAppOnExe var model = app.Services.GetRequiredService(); - var container = Assert.Single(model.GetContainerResources()); + var container = Assert.IsType(Assert.Single(model.GetContainerResources()), exactMatch: false); var target = container.GetDeploymentTargetAnnotation(); - Assert.Same(env.Resource, target?.ComputeEnvironment); + Assert.NotNull(target); + Assert.Same(env.Resource, target.ComputeEnvironment); var resource = target?.DeploymentTarget as AzureProvisioningResource; Assert.NotNull(resource); @@ -498,17 +502,17 @@ public async Task CanTweakContainerAppEnvironmentUsingPublishAsContainerAppOnExe """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -607,15 +611,15 @@ public async Task AddContainerAppsInfrastructureWithParameterReference() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param value string - + param minReplicas string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -679,11 +683,11 @@ public async Task AddContainerAppsEntrypointAndArgs() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -831,46 +835,46 @@ public async Task ProjectWithManyReferenceTypes() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_identity_outputs_id string - + param api_identity_outputs_clientid string - + param api_containerport string - + param mydb_outputs_connectionstring string - + param storage_outputs_blobendpoint string - + param pg_kv_outputs_name string - + @secure() param value0_value string - + param value1_value string - + @secure() param cs_connectionstring string - + param env_outputs_azure_container_apps_environment_default_domain string - + param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource pg_kv_outputs_name_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: pg_kv_outputs_name } - + resource pg_kv_outputs_name_kv_connectionstrings__db 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'connectionstrings--db' parent: pg_kv_outputs_name_kv } - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1044,9 +1048,9 @@ param api_containerimage string } output id string = api_identity.id - + output clientId string = api_identity.properties.clientId - + output principalId string = api_identity.properties.principalId output principalName string = api_identity.name @@ -1243,11 +1247,11 @@ public async Task PublishAsContainerAppInfluencesContainerAppDefinition() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1328,15 +1332,15 @@ public async Task ConfigureCustomDomainMutatesIngress() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param certificateName string - + param customDomain string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1432,17 +1436,17 @@ public async Task ConfigureDuplicateCustomDomainMutatesIngress() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param initialCertificateName string - + param customDomain string - + param expectedCertificateName string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1541,19 +1545,19 @@ public async Task ConfigureMultipleCustomDomainsMutatesIngress() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param certificateName1 string - + param customDomain1 string - + param certificateName2 string - + param customDomain2 string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1790,43 +1794,43 @@ public async Task SecretOutputHandling() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_identity_outputs_id string - + param api_identity_outputs_clientid string - + param mydb_kv_outputs_name string - + param mydb_secretoutputs string - + @secure() param mydb_secretoutputs_connectionstring string - + @secure() param mydb_secretoutputs_connectionstring1 string - + param outputs_azure_container_apps_environment_default_domain string param outputs_azure_container_apps_environment_id string - + resource mydb_kv_outputs_name_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: mydb_kv_outputs_name } - + resource mydb_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: mydb_secretoutputs } - + resource mydb_kv_outputs_name_kv_connectionstrings__mydb 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'connectionstrings--mydb' parent: mydb_kv_outputs_name_kv } - + resource mydb_secretoutputs_kv_connectionString 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = { name: 'connectionString' parent: mydb_secretoutputs_kv } - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -1983,11 +1987,11 @@ public async Task CanCustomizeWithProvisioningBuildOptions() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api1 'Microsoft.App/containerApps@2024-03-01' = { name: 'api1-my' location: location @@ -2074,11 +2078,11 @@ public async Task ExternalEndpointBecomesIngress() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2157,11 +2161,11 @@ public async Task FirstHttpEndpointBecomesIngress() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2247,11 +2251,11 @@ public async Task EndpointWithHttp2SetsTransportToH2() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2334,19 +2338,19 @@ public async Task ProjectUsesTheTargetPortAsADefaultPortForFirstHttpEndpoint() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_containerport string - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2502,21 +2506,21 @@ public async Task RoleAssignmentsWithAsExisting() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_identity_outputs_id string - + param api_identity_outputs_clientid string - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2707,23 +2711,23 @@ public async Task RoleAssignmentsWithAsExistingCosmosDB() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_identity_outputs_id string - + param api_identity_outputs_clientid string - + param cosmos_outputs_connectionstring string - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -2921,23 +2925,23 @@ public async Task RoleAssignmentsWithAsExistingRedis() """ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location - + param api_identity_outputs_id string - + param api_identity_outputs_clientid string - + param redis_outputs_connectionstring string - + param env_outputs_azure_container_apps_environment_default_domain string param env_outputs_azure_container_apps_environment_id string - + param env_outputs_azure_container_registry_endpoint string - + param env_outputs_azure_container_registry_managed_identity_id string - + param api_containerimage string - + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location @@ -3662,6 +3666,299 @@ param principalName string Assert.Equal(expectedBicep, bicep); } + [Fact] + public async Task ContainerAppEnvironmentWithCustomRegistry() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + // Create a custom registry + var registry = builder.AddAzureContainerRegistry("customregistry"); + + // Create a container app environment and associate it with the custom registry + builder.AddAzureContainerAppEnvironment("env") + .WithAzureContainerRegistry(registry); + + // Add a container that will use the environment + builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint(); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + // Verify environment resource exists + var environment = Assert.Single(model.Resources.OfType()); + + // Verify project resource exists + var project = Assert.Single(model.GetProjectResources()); + + // Get the bicep for the environment + var (envManifest, envBicep) = await GetManifestWithBicep(environment); + + // Verify the environment manifest + var expectedEnvManifest = + """ + { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "customregistry_outputs_name": "{customregistry.outputs.name}", + "userPrincipalId": "" + } + } + """; + + Assert.Equal(expectedEnvManifest, envManifest.ToString()); + + // Use existing reference for registry, assign ACR pull role to the environment's managed identity + // and set the registry as the container's registry via the output references + var expectedEnvBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param userPrincipalId string + + param tags object = { } + + param customregistry_outputs_name string + + resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags + } + + resource customregistry 'Microsoft.ContainerRegistry/registries@2023-07-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@2023-09-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags + } + + resource env 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags + } + + resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env + } + + resource env_Contributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env.id, userPrincipalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')) + properties: { + principalId: userPrincipalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') + } + scope: env + } + + output MANAGED_IDENTITY_NAME string = env_mi.name + + output MANAGED_IDENTITY_PRINCIPAL_ID string = env_mi.properties.principalId + + output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + + output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + + output AZURE_CONTAINER_REGISTRY_NAME string = customregistry_outputs_name + + output AZURE_CONTAINER_REGISTRY_ENDPOINT string = customregistry.properties.loginServer + + output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.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 + """; + Assert.Equal(expectedEnvBicep, envBicep); + + // Verify container has correct deployment target + project.TryGetLastAnnotation(out var target); + var projectResource = target?.DeploymentTarget as AzureProvisioningResource; + Assert.NotNull(projectResource); + + // Get the bicep for the container + var (containerManifest, containerBicep) = await GetManifestWithBicep(projectResource); + + // Verify container manifest references the environment outputs + var expectedContainerManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "api_containerport": "{api.containerPort}", + "env_outputs_azure_container_apps_environment_default_domain": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}", + "env_outputs_azure_container_apps_environment_id": "{env.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "env_outputs_azure_container_registry_endpoint": "{env.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "env_outputs_azure_container_registry_managed_identity_id": "{env.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "api_containerimage": "{api.containerImage}" + } + } + """; + + Assert.Equal(expectedContainerManifest, containerManifest.ToString()); + + var expectedContainerBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param api_containerport string + + param env_outputs_azure_container_apps_environment_default_domain string + + param env_outputs_azure_container_apps_environment_id string + + param env_outputs_azure_container_registry_endpoint string + + param env_outputs_azure_container_registry_managed_identity_id string + + param api_containerimage string + + resource api 'Microsoft.App/containerApps@2024-03-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: api_containerport + transport: 'http' + } + registries: [ + { + server: env_outputs_azure_container_registry_endpoint + identity: env_outputs_azure_container_registry_managed_identity_id + } + ] + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + Assert.Equal(expectedContainerBicep, containerBicep); + + // Verify the Azure Container Registry resource manifest and bicep + var containerRegistry = Assert.Single(model.Resources.OfType()); + var (registryManifest, registryBicep) = await GetManifestWithBicep(containerRegistry); + + var expectedRegistryManifest = + """ + { + "type": "azure.bicep.v0", + "path": "customregistry.module.bicep" + } + """; + Assert.Equal(expectedRegistryManifest, registryManifest.ToString()); + + var expectedRegistryBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + resource customregistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: take('customregistry${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'customregistry' + } + } + + output name string = customregistry.name + + output loginServer string = customregistry.properties.loginServer + """; + Assert.Equal(expectedRegistryBicep, registryBicep); + } + private static Task<(JsonNode ManifestNode, string BicepText)> GetManifestWithBicep(IResource resource) => AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerRegistryTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerRegistryTests.cs new file mode 100644 index 00000000000..54d80208437 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerRegistryTests.cs @@ -0,0 +1,179 @@ +#pragma warning disable ASPIRECOMPUTE001 + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.AppContainers; +using Aspire.Hosting.Azure.ContainerRegistry; +using Aspire.Hosting.Utils; +using Azure.Provisioning.ContainerRegistry; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using static Aspire.Hosting.Utils.AzureManifestUtils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureContainerRegistryTests(ITestOutputHelper output) +{ + [Fact] + public async Task AddAzureContainerRegistry_AddsResourceAndImplementsIContainerRegistry() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + _ = builder.AddAzureContainerRegistry("acr"); + + // Build & execute hooks + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var registryResource = Assert.Single(model.Resources.OfType()); + var registryInterface = Assert.IsType(registryResource, exactMatch: false); + + Assert.NotNull(registryInterface); + Assert.NotNull(registryInterface.Name); + Assert.NotNull(registryInterface.Endpoint); + } + + [Fact] + public async Task WithRegistry_AttachesContainerRegistryReferenceAnnotation() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var registryBuilder = builder.AddAzureContainerRegistry("acr"); + _ = builder.AddAzureContainerAppEnvironment("env") + .WithAzureContainerRegistry(registryBuilder); // Extension method under test + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var environment = Assert.Single(model.Resources.OfType()); + + Assert.True(environment.TryGetLastAnnotation(out var annotation)); + Assert.Same(registryBuilder.Resource, annotation!.Registry); + } + + [Fact] + public async Task AddAzureContainerRegistry_GeneratesCorrectManifestAndBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var acr = builder.AddAzureContainerRegistry("acr"); + + var manifest = await GetManifestWithBicep(acr.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v0", + "path": "acr.module.bicep" + } + """; + + output.WriteLine(manifest.ManifestNode.ToString()); + Assert.Equal(expectedManifest, manifest.ManifestNode.ToString()); + + var expectedBicep = """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: take('acr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'acr' + } + } + + output name string = acr.name + + output loginServer string = acr.properties.loginServer + """; + + output.WriteLine(manifest.BicepText); + Assert.Equal(expectedBicep, manifest.BicepText); + } + + [Fact] + public async Task WithRoleAssignments_GeneratesCorrectRoleAssignmentBicep() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + // Add container app environment since it's required for role assignments + builder.AddAzureContainerAppEnvironment("env"); + + // Create a container registry and assign roles to a project + var acr = builder.AddAzureContainerRegistry("acr"); + builder.AddProject("api", launchProfileName: null) + .WithRoleAssignments(acr, ContainerRegistryBuiltInRole.AcrPull, ContainerRegistryBuiltInRole.AcrPush); + + using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var rolesResource = Assert.Single(model.Resources.OfType(), r => r.Name == "api-roles-acr"); + + var (rolesManifest, rolesBicep) = await GetManifestWithBicep(rolesResource); + + var expectedRolesManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api-roles-acr.module.bicep", + "params": { + "acr_outputs_name": "{acr.outputs.name}", + "principalId": "{api-identity.outputs.principalId}" + } + } + """; + + Assert.Equal(expectedRolesManifest, rolesManifest.ToString()); + + var expectedRolesBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param acr_outputs_name string + + param principalId string + + resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acr_outputs_name + } + + resource acr_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: acr + } + + resource acr_AcrPush 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8311e382-0749-4cb8-b61a-304f252e45ec')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8311e382-0749-4cb8-b61a-304f252e45ec') + principalType: 'ServicePrincipal' + } + scope: acr + } + """; + + output.WriteLine(rolesBicep); + Assert.Equal(expectedRolesBicep, rolesBicep); + } + + private sealed class Project : IProjectMetadata + { + public string ProjectPath => "project"; + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/ContainerRegistryTests.cs b/tests/Aspire.Hosting.Azure.Tests/ContainerRegistryTests.cs index 4d2c0de0250..1befec70787 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ContainerRegistryTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ContainerRegistryTests.cs @@ -64,10 +64,10 @@ public async Task ContainerRegistryInfoFlowsToDeploymentTargetForProjects() Assert.NotNull(target); // Verify that ContainerRegistryInfo property is not null for project resources - Assert.NotNull(target.ContainerRegistryInfo); + Assert.NotNull(target.ContainerRegistry); // Verify that ContainerRegistryInfo is of type IContainerRegistry - var registry = Assert.IsType(target.ContainerRegistryInfo, exactMatch: false); + var registry = Assert.IsType(target.ContainerRegistry, exactMatch: false); // Verify registry properties are available Assert.NotNull(registry.Name); @@ -157,10 +157,10 @@ public Task PublishAsync(DistributedApplicationModel model, CancellationToken ca foreach (var resource in model.Resources) { if (resource.TryGetLastAnnotation(out var annotation) && - annotation.ContainerRegistryInfo != null) + annotation.ContainerRegistry != null) { ComputeResourceRegistryFound = true; - ComputeResourceRegistry = annotation.ContainerRegistryInfo; + ComputeResourceRegistry = annotation.ContainerRegistry; if (ComputeResourceRegistry is IAzureContainerRegistry azureRegistry) { AzureContainerRegistry = azureRegistry; diff --git a/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs index 70a48fa0516..373a89f664c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ExistingAzureResourceTests.cs @@ -1391,4 +1391,92 @@ param keyVaultName string output.WriteLine(BicepText); Assert.Equal(expectedBicep, BicepText); } + + [Fact] + public async Task SupportsExistingAzureContainerRegistryInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var existingResourceName = builder.AddParameter("existingResourceName"); + var acr = builder.AddAzureContainerRegistry("acr") + .RunAsExisting(existingResourceName, resourceGroupParameter: default); + + var (ManifestNode, BicepText) = await AzureManifestUtils.GetManifestWithBicep(acr.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v0", + "path": "acr.module.bicep", + "params": { + "existingResourceName": "{existingResourceName.value}" + } + } + """; + + Assert.Equal(expectedManifest, ManifestNode.ToString()); + + var expectedBicep = """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param existingResourceName string + + resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: existingResourceName + } + + output name string = existingResourceName + + output loginServer string = acr.properties.loginServer + """; + + output.WriteLine(BicepText); + Assert.Equal(expectedBicep, BicepText); + } + + [Fact] + public async Task SupportsExistingAzureContainerRegistryInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var existingResourceName = builder.AddParameter("existingResourceName"); + var existingResourceGroupName = builder.AddParameter("existingResourceGroupName"); + var acr = builder.AddAzureContainerRegistry("acr") + .PublishAsExisting(existingResourceName, existingResourceGroupName); + + var (ManifestNode, BicepText) = await AzureManifestUtils.GetManifestWithBicep(acr.Resource); + + var expectedManifest = """ + { + "type": "azure.bicep.v1", + "path": "acr.module.bicep", + "params": { + "existingResourceName": "{existingResourceName.value}" + }, + "scope": { + "resourceGroup": "{existingResourceGroupName.value}" + } + } + """; + + Assert.Equal(expectedManifest, ManifestNode.ToString()); + + var expectedBicep = """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param existingResourceName string + + resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: existingResourceName + } + + output name string = existingResourceName + + output loginServer string = acr.properties.loginServer + """; + + output.WriteLine(BicepText); + Assert.Equal(expectedBicep, BicepText); + } } diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 544e678078d..283ab203a3e 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -31,4 +31,4 @@ public async Task DockerComposeSetsComputeEnvironment() [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/tests/Shared/RepoTesting/Directory.Packages.Helix.props b/tests/Shared/RepoTesting/Directory.Packages.Helix.props index 3a74f01ca7e..09b26eff193 100644 --- a/tests/Shared/RepoTesting/Directory.Packages.Helix.props +++ b/tests/Shared/RepoTesting/Directory.Packages.Helix.props @@ -22,6 +22,7 @@ +