From efe52578af5029d8e842a4f1945b03744817027c Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 14 Oct 2024 09:03:09 +1100 Subject: [PATCH] ConfigureCustomDomain extension method. --- .../AzureContainerApps.AppHost/Program.cs | 7 +- .../api.module.bicep | 11 ++ .../aspire-manifest.json | 24 ++++- .../ContainerAppExtensions.cs | 100 ++++++++++++++++++ .../PublicAPI.Unshipped.txt | 2 + .../AzureContainerAppsTests.cs | 98 +++++++++++++++++ 6 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs index 0e64f11122f..244801abd21 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs @@ -1,7 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. + var builder = DistributedApplication.CreateBuilder(args); +var customDomain = builder.AddParameter("customDomain"); +var certificateName = builder.AddParameter("certificateName"); + // Testing secret parameters var param = builder.AddParameter("secretparam", "fakeSecret", secret: true); @@ -28,6 +32,8 @@ .WithEnvironment("VALUE", param) .PublishAsAzureContainerApp((module, app) => { + app.ConfigureCustomDomain(customDomain, certificateName); + // Scale to 0 app.Template.Value!.Scale.Value!.MinReplicas = 0; }); @@ -43,4 +49,3 @@ #endif builder.Build().Run(); - diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep index a0e7a90adb1..b634633c7c2 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep @@ -20,6 +20,10 @@ param outputs_azure_container_registry_endpoint string param api_containerimage string +param certificateName string + +param customDomain string + resource account_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = { name: account_secretoutputs } @@ -50,6 +54,13 @@ resource api 'Microsoft.App/containerApps@2024-03-01' = { external: true targetPort: api_containerport transport: 'http' + customDomains: [ + { + name: customDomain + bindingType: (certificateName != '') ? 'SniEnabled' : 'Disabled' + certificateId: (certificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName}' : null + } + ] } registries: [ { diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json index 1aa3ff2eec7..9cd212e6d31 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json @@ -1,6 +1,24 @@ { "$schema": "https://json.schemastore.org/aspire-8.0.json", "resources": { + "customDomain": { + "type": "parameter.v0", + "value": "{customDomain.inputs.value}", + "inputs": { + "value": { + "type": "string" + } + } + }, + "certificateName": { + "type": "parameter.v0", + "value": "{certificateName.inputs.value}", + "inputs": { + "value": { + "type": "string" + } + } + }, "secretparam": { "type": "parameter.v0", "value": "{secretparam.inputs.value}", @@ -32,7 +50,7 @@ ], "volumes": [ { - "name": "azurecontainerapps.apphost-b5fb0098a7-cache-data", + "name": "azurecontainerapps.apphost-43a728061e-cache-data", "target": "/data", "readOnly": false } @@ -72,6 +90,10 @@ "deployment": { "type": "azure.bicep.v0", "path": "api.module.bicep", + "params": { + "certificateName": "{certificateName.value}", + "customDomain": "{customDomain.value}" + }, "params": { "api_containerport": "{api.containerPort}", "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs new file mode 100644 index 00000000000..7b3900d82ae --- /dev/null +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs @@ -0,0 +1,100 @@ +// 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.AppContainers; +using Azure.Provisioning.Expressions; +using Azure.Provisioning; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for customizing Azure Container App resource. +/// +public static class ContainerAppExtensions +{ + /// + /// Configures the custom domain for the container app. + /// + /// 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. + /// Throws if the container app resource is not parented to a . + /// + /// The extension method + /// simplifies the process of assigning a custom domain to a container app resource when it is deployed. It has no impact on local development. + /// The method is used + /// in conjunction with the + /// callback. Assigning a custom domain to a container app resource is a multi-step process and requires multiple deployments. + /// The method takes + /// 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 + /// 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 + /// can by modified with the certificate name since Azure Developer CLI will not prompt again for the value. + /// + /// + /// This example shows declaring two parameters to capture the custom domain and certificate name and + /// passing them to the + /// method via the + /// extension method. + /// + /// var builder = DistributedApplication.CreateBuilder(); + /// var customDomain = builder.AddParameter("customDomain"); // Value provided at first deployment. + /// var certificateName = builder.AddParameter("certificateName"); // Value provided at second and subsequent deployments. + /// builder.AddProject<Projects.InventoryService>("inventory") + /// .PublishAsAzureContainerApp((module, app) => + /// { + /// app.ConfigureCustomDomain(customDomain, certificateName); + /// }); + /// + /// + public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder customDomain, IResourceBuilder certificateName) + { + if (app.ParentInfrastructure is not ResourceModuleConstruct module) + { + throw new ArgumentException("Cannot configure custom domain when resource is not parented by ResourceModuleConstruct.", nameof(app)); + } + + var containerAppManagedEnvironmentIdParameter = module.GetResources().OfType().Single( + p => p.IdentifierName == "outputs_azure_container_apps_environment_id"); + var certificatNameParameter = certificateName.AsProvisioningParameter(module); + var customDomainParameter = customDomain.AsProvisioningParameter(module); + + var bindingTypeConditional = new ConditionalExpression( + new BinaryExpression( + new IdentifierExpression(certificatNameParameter.IdentifierName), + BinaryOperator.NotEqual, + new StringLiteral(string.Empty)), + new StringLiteral("SniEnabled"), + new StringLiteral("Disabled") + ); + + var certificateOrEmpty = new ConditionalExpression( + new BinaryExpression( + new IdentifierExpression(certificatNameParameter.IdentifierName), + BinaryOperator.NotEqual, + new StringLiteral(string.Empty)), + new InterpolatedString( + "{0}/managedCertificates/{1}", + [ + new IdentifierExpression(containerAppManagedEnvironmentIdParameter.IdentifierName), + new IdentifierExpression(certificatNameParameter.IdentifierName) + ]), + new NullLiteral() + ); + + app.Configuration.Value!.Ingress!.Value!.CustomDomains = new BicepList() + { + new ContainerAppCustomDomain() + { + BindingType = bindingTypeConditional, + Name = new IdentifierExpression(customDomainParameter.IdentifierName), + CertificateId = certificateOrEmpty + } + }; + } +} diff --git a/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt index 6daf254f75c..45d4e64faa4 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt @@ -2,6 +2,8 @@ Aspire.Hosting.AzureContainerAppContainerExtensions Aspire.Hosting.AzureContainerAppExtensions Aspire.Hosting.AzureContainerAppProjectExtensions +Aspire.Hosting.ContainerAppExtensions static Aspire.Hosting.AzureContainerAppContainerExtensions.PublishAsAzureContainerApp(this Aspire.Hosting.ApplicationModel.IResourceBuilder! container, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureContainerAppExtensions.AddContainerAppsInfrastructure(this Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.IDistributedApplicationBuilder! static Aspire.Hosting.AzureContainerAppProjectExtensions.PublishAsAzureContainerApp(this Aspire.Hosting.ApplicationModel.IResourceBuilder! project, System.Action! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.ContainerAppExtensions.ConfigureCustomDomain(this Azure.Provisioning.AppContainers.ContainerApp! app, Aspire.Hosting.ApplicationModel.IResourceBuilder! customDomain, Aspire.Hosting.ApplicationModel.IResourceBuilder! certificateName) -> void diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 253fb094883..d673bab24b3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -620,6 +620,104 @@ param outputs_azure_container_apps_environment_id string Assert.Equal(expectedBicep, bicep); } + [Fact] + public async Task ConfigureCustomDomainsMutatesIngress() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var customDomain = builder.AddParameter("customDomain"); + var certificateName = builder.AddParameter("certificateName"); + + builder.AddContainerAppsInfrastructure(); + builder.AddContainer("api", "myimage") + .WithHttpEndpoint(targetPort: 1111) + .PublishAsAzureContainerApp((module, c) => + { + c.ConfigureCustomDomain(customDomain, certificateName); + }); + + 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 AzureConstructResource; + + 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}" + } + } + """; + + 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 + + resource api 'Microsoft.App/containerApps@2024-03-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + } + 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: 0 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${outputs_azure_container_registry_managed_identity_id}': { } + } + } + } + """; + output.WriteLine(bicep); + Assert.Equal(expectedBicep, bicep); + } + [Fact] public async Task VolumesAndBindMountsAreTranslation() {