From e2bc9e9bbcdf19b27c2e3bee6942b0b242e2f1f6 Mon Sep 17 00:00:00 2001 From: mbrown379 Date: Sat, 15 Feb 2025 09:27:44 -0500 Subject: [PATCH 1/2] Add or update custom domains --- .../ContainerAppExtensions.cs | 29 +- .../AzureContainerAppsTests.cs | 251 +++++++++++++++++- 2 files changed, 268 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs index bce9c2ba7c9..6d966a18de2 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs @@ -20,7 +20,7 @@ public static class ContainerAppExtensions /// /// The container app resource to configure for custom domain usage. /// A resource builder for a parameter resource capturing the name of the custom domain. - /// A resource builder for a parameter resource capturing the name of the certficate configured in the Azure Portal. + /// A resource builder for a parameter resource capturing the name of the certificate configured in the Azure Portal. /// Throws if the container app resource is not parented to a . /// /// The extension method @@ -32,7 +32,7 @@ public static class ContainerAppExtensions /// two arguments which are parameter resource builders. The first is a parameter that represents the custom domain and the second is a parameter that /// represents the name of the managed certificate provisioned via the Azure Portal /// When deploying with custom domains configured for the first time leave the parameter empty (when prompted - /// by the Azure Developer CLI). Once the applicatio is deployed acucessfully access to the Azure Portal to bind the custom domain to a managed SSL + /// by the Azure Developer CLI). Once the application is deployed successfully access to the Azure Portal to bind the custom domain to a managed SSL /// certificate. Once the certificate is successfully provisioned, subsequent deployments of the application can use this certificate name when the /// is prompted. /// For deployments triggered locally by the Azure Developer CLI the config.json file in the .azure/{environment name} path @@ -90,14 +90,21 @@ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder new NullLiteralExpression() ); - app.Configuration.Ingress.CustomDomains = new BicepList() - { - new ContainerAppCustomDomain() - { - BindingType = bindingTypeConditional, - Name = customDomainParameter, - CertificateId = certificateOrEmpty - } - }; + var containerAppCustomDomain = new ContainerAppCustomDomain() + { + BindingType = bindingTypeConditional, + Name = customDomainParameter, + CertificateId = certificateOrEmpty + }; + + var existingCustomDomain = app.Configuration.Ingress.CustomDomains + .FirstOrDefault(cd => cd.Value?.Name.Value == containerAppCustomDomain.Name.Value); + + if (existingCustomDomain is not null) + { + app.Configuration.Ingress.CustomDomains.Remove(existingCustomDomain); + } + + app.Configuration.Ingress.CustomDomains.Add(containerAppCustomDomain); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 0e01a142ad5..87b48cbb3f4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1061,7 +1061,7 @@ param outputs_azure_container_apps_environment_id string } [Fact] - public async Task ConfigureCustomDomainsMutatesIngress() + public async Task ConfigureCustomDomainMutatesIngress() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); @@ -1176,6 +1176,255 @@ param customDomain string Assert.Equal(expectedBicep, bicep); } + [Fact] + public async Task ConfigureDuplicateCustomDomainMutatesIngress() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var customDomain = builder.AddParameter("customDomain"); + var initialCertificateName = builder.AddParameter("initialCertificateName"); + var expectedCertificateName = builder.AddParameter("expectedCertificateName"); + + builder.AddAzureContainerAppsInfrastructure(); + builder.AddContainer("api", "myimage") + .WithHttpEndpoint(targetPort: 1111) + .PublishAsAzureContainerApp((module, c) => + { + c.ConfigureCustomDomain(customDomain, initialCertificateName); + c.ConfigureCustomDomain(customDomain, expectedCertificateName); + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = Assert.Single(model.GetContainerResources()); + + container.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureBicepResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "expectedCertificateName": "{expectedCertificateName.value}", + "customDomain": "{customDomain.value}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param outputs_azure_container_registry_managed_identity_id string + + param outputs_managed_identity_client_id string + + param outputs_azure_container_apps_environment_id string + + param expectedCertificateName string + + param customDomain string + + resource api 'Microsoft.App/containerApps@2024-03-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: 1111 + transport: 'http' + customDomains: [ + { + name: customDomain + bindingType: (expectedCertificateName != '') ? 'SniEnabled' : 'Disabled' + certificateId: (expectedCertificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${expectedCertificateName}' : null + } + ] + } + } + environmentId: outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'myimage:latest' + name: 'api' + env: [ + { + name: 'AZURE_CLIENT_ID' + value: outputs_managed_identity_client_id + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + + [Fact] + public async Task ConfigureMultipleCustomDomainsMutatesIngress() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var customDomain1 = builder.AddParameter("customDomain1"); + var certificateName1 = builder.AddParameter("certificateName1"); + + var customDomain2 = builder.AddParameter("customDomain2"); + var certificateName2 = builder.AddParameter("certificateName2"); + + builder.AddAzureContainerAppsInfrastructure(); + builder.AddContainer("api", "myimage") + .WithHttpEndpoint(targetPort: 1111) + .PublishAsAzureContainerApp((module, c) => + { + c.ConfigureCustomDomain(customDomain1, certificateName1); + c.ConfigureCustomDomain(customDomain2, certificateName2); + }); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var container = Assert.Single(model.GetContainerResources()); + + container.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureBicepResource; + + Assert.NotNull(resource); + + var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource); + + var m = manifest.ToString(); + + var expectedManifest = + """ + { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "certificateName1": "{certificateName1.value}", + "customDomain1": "{customDomain1.value}", + "certificateName2": "{certificateName2.value}", + "customDomain2": "{customDomain2.value}" + } + } + """; + + Assert.Equal(expectedManifest, m); + + var expectedBicep = + """ + @description('The location for the resource(s) to be deployed.') + param location string = resourceGroup().location + + param outputs_azure_container_registry_managed_identity_id string + + param outputs_managed_identity_client_id string + + param 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 + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: 1111 + transport: 'http' + customDomains: [ + { + name: customDomain1 + bindingType: (certificateName1 != '') ? 'SniEnabled' : 'Disabled' + certificateId: (certificateName1 != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName1}' : null + } + { + name: customDomain2 + bindingType: (certificateName2 != '') ? 'SniEnabled' : 'Disabled' + certificateId: (certificateName2 != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName2}' : null + } + ] + } + } + environmentId: outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'myimage:latest' + name: 'api' + env: [ + { + name: 'AZURE_CLIENT_ID' + value: outputs_managed_identity_client_id + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + [Fact] public async Task VolumesAndBindMountsAreTranslation() { From 96e0fc6ca7ad47de4e76ec2b8f5debd6bf797f18 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 18 Feb 2025 14:09:09 +1100 Subject: [PATCH 2/2] Fixing tests Fixing tests (some work arounds for the implicit operators in CDK). --- .../ContainerAppExtensions.cs | 14 +++++++++++++- .../AzureContainerAppsTests.cs | 9 ++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs index 6d966a18de2..cda29301cdf 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs @@ -98,7 +98,19 @@ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder }; var existingCustomDomain = app.Configuration.Ingress.CustomDomains - .FirstOrDefault(cd => cd.Value?.Name.Value == containerAppCustomDomain.Name.Value); + .FirstOrDefault(cd => { + // This is a cautionary tale to anyone who reads this code as to the dangers + // of using implicit conversions in C#. BicepValue uses some implicit conversions + // which means we need to explicitly cast to IBicepValue so that we can get at the + // source construct behind the Bicep value on the "name" field for a custom domain + // in the Bicep. If the constructs are the same ProvisioningParameter then we have a + // match - otherwise we are possibly dealing with a second domain. This deals with the + // edge case of where someone might call ConfigureCustomDomain multiple times on the + // same domain - unlikely but possible if someone has built some libraries. + var itemDomainNameBicepValue = cd.Value?.Name as IBicepValue; + var candidateDomainNameBicepValue = containerAppCustomDomain.Name as IBicepValue; + return itemDomainNameBicepValue?.Source?.Construct == candidateDomainNameBicepValue.Source?.Construct; + }); if (existingCustomDomain is not null) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 87b48cbb3f4..567bf552c2a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1221,8 +1221,9 @@ public async Task ConfigureDuplicateCustomDomainMutatesIngress() "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", - "expectedCertificateName": "{expectedCertificateName.value}", - "customDomain": "{customDomain.value}" + "initialCertificateName": "{initialCertificateName.value}", + "customDomain": "{customDomain.value}", + "expectedCertificateName": "{expectedCertificateName.value}" } } """; @@ -1240,10 +1241,12 @@ param outputs_managed_identity_client_id string param outputs_azure_container_apps_environment_id string - param expectedCertificateName string + param initialCertificateName string param customDomain string + param expectedCertificateName string + resource api 'Microsoft.App/containerApps@2024-03-01' = { name: 'api' location: location