diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs
index 0e64f11122f..0e0aff76d49 100644
--- a/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs
+++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs
@@ -1,7 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREACADOMAINS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
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 +34,8 @@
.WithEnvironment("VALUE", param)
.PublishAsAzureContainerApp((module, app) =>
{
+ app.ConfigureCustomDomain(customDomain, certificateName);
+
// Scale to 0
app.Template.Value!.Scale.Value!.MinReplicas = 0;
});
@@ -43,4 +51,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 1ae54135e1e..f5878d17970 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..f1f004d121b
--- /dev/null
+++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs
@@ -0,0 +1,103 @@
+// 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;
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Hosting.Azure;
+
+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);
+ /// });
+ ///
+ ///
+ [Experimental("ASPIREACADOMAINS001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
+ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder customDomain, IResourceBuilder certificateName)
+ {
+ if (app.ParentInfrastructure is not AzureResourceInfrastructure 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 8569762abac..4ae8b8a5e53 100644
--- a/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt
+++ b/src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt
@@ -5,6 +5,8 @@ Aspire.Hosting.Azure.AzureContainerAppCustomizationAnnotation.Configure.get -> S
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.AddAzureContainerAppsInfrastructure(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 70809140398..a295117147e 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#pragma warning disable ASPIREACADOMAINS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
@@ -736,6 +738,122 @@ 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.AddAzureContainerAppsInfrastructure();
+ 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 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}",
+ "certificateName": "{certificateName.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 certificateName 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: (certificateName != '') ? 'SniEnabled' : 'Disabled'
+ certificateId: (certificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName}' : 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()
{