Skip to content

Commit 8b88522

Browse files
[release/9.0] Experimental custom domain support for ACA. (#6391)
* ConfigureCustomDomain extension method. * Test case fixup. * Add experimental attribute. * React to changes on main. --------- Co-authored-by: Mitch Denny <midenn@microsoft.com>
1 parent d99b419 commit 8b88522

File tree

6 files changed

+265
-2
lines changed

6 files changed

+265
-2
lines changed

playground/AzureContainerApps/AzureContainerApps.AppHost/Program.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#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.
5+
36
var builder = DistributedApplication.CreateBuilder(args);
47

8+
var customDomain = builder.AddParameter("customDomain");
9+
var certificateName = builder.AddParameter("certificateName");
10+
511
// Testing secret parameters
612
var param = builder.AddParameter("secretparam", "fakeSecret", secret: true);
713

@@ -33,6 +39,8 @@
3339
.WithEnvironment("VALUE", param)
3440
.PublishAsAzureContainerApp((module, app) =>
3541
{
42+
app.ConfigureCustomDomain(customDomain, certificateName);
43+
3644
// Scale to 0
3745
app.Template.Value!.Scale.Value!.MinReplicas = 0;
3846
});
@@ -48,4 +56,3 @@
4856
#endif
4957

5058
builder.Build().Run();
51-

playground/AzureContainerApps/AzureContainerApps.AppHost/api.module.bicep

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ param outputs_azure_container_registry_endpoint string
2020

2121
param api_containerimage string
2222

23+
param certificateName string
24+
25+
param customDomain string
26+
2327
resource account_secretoutputs_kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
2428
name: account_secretoutputs
2529
}
@@ -50,6 +54,13 @@ resource api 'Microsoft.App/containerApps@2024-03-01' = {
5054
external: true
5155
targetPort: api_containerport
5256
transport: 'http'
57+
customDomains: [
58+
{
59+
name: customDomain
60+
bindingType: (certificateName != '') ? 'SniEnabled' : 'Disabled'
61+
certificateId: (certificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName}' : null
62+
}
63+
]
5364
}
5465
registries: [
5566
{

playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
{
22
"$schema": "https://json.schemastore.org/aspire-8.0.json",
33
"resources": {
4+
"customDomain": {
5+
"type": "parameter.v0",
6+
"value": "{customDomain.inputs.value}",
7+
"inputs": {
8+
"value": {
9+
"type": "string"
10+
}
11+
}
12+
},
13+
"certificateName": {
14+
"type": "parameter.v0",
15+
"value": "{certificateName.inputs.value}",
16+
"inputs": {
17+
"value": {
18+
"type": "string"
19+
}
20+
}
21+
},
422
"secretparam": {
523
"type": "parameter.v0",
624
"value": "{secretparam.inputs.value}",
@@ -32,7 +50,7 @@
3250
],
3351
"volumes": [
3452
{
35-
"name": "azurecontainerapps.apphost-b5fb0098a7-cache-data",
53+
"name": "azurecontainerapps.apphost-43a728061e-cache-data",
3654
"target": "/data",
3755
"readOnly": false
3856
}
@@ -90,6 +108,10 @@
90108
"deployment": {
91109
"type": "azure.bicep.v0",
92110
"path": "api.module.bicep",
111+
"params": {
112+
"certificateName": "{certificateName.value}",
113+
"customDomain": "{customDomain.value}"
114+
},
93115
"params": {
94116
"api_containerport": "{api.containerPort}",
95117
"storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Hosting.ApplicationModel;
5+
using Azure.Provisioning.AppContainers;
6+
using Azure.Provisioning.Expressions;
7+
using Azure.Provisioning;
8+
using System.Diagnostics.CodeAnalysis;
9+
using Aspire.Hosting.Azure;
10+
11+
namespace Aspire.Hosting;
12+
13+
/// <summary>
14+
/// Provides extension methods for customizing Azure Container App resource.
15+
/// </summary>
16+
public static class ContainerAppExtensions
17+
{
18+
/// <summary>
19+
/// Configures the custom domain for the container app.
20+
/// </summary>
21+
/// <param name="app">The container app resource to configure for custom domain usage.</param>
22+
/// <param name="customDomain">A resource builder for a parameter resource capturing the name of the custom domain.</param>
23+
/// <param name="certificateName">A resource builder for a parameter resource capturing the name of the certficate configured in the Azure Portal.</param>
24+
/// <exception cref="ArgumentException">Throws if the container app resource is not parented to a <see cref="AzureResourceInfrastructure"/>.</exception>
25+
/// <remarks>
26+
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> extension method
27+
/// simplifies the process of assigning a custom domain to a container app resource when it is deployed. It has no impact on local development.</para>
28+
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> method is used
29+
/// in conjunction with the <see cref="AzureContainerAppContainerExtensions.PublishAsAzureContainerApp{T}(IResourceBuilder{T}, Action{AzureResourceInfrastructure, ContainerApp})"/>
30+
/// callback. Assigning a custom domain to a container app resource is a multi-step process and requires multiple deployments.</para>
31+
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> method takes
32+
/// two arguments which are parameter resource builders. The first is a parameter that represents the custom domain and the second is a parameter that
33+
/// represents the name of the managed certificate provisioned via the Azure Portal</para>
34+
/// <para>When deploying with custom domains configured for the first time leave the <paramref name="certificateName"/> parameter empty (when prompted
35+
/// 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
36+
/// certificate. Once the certificate is successfully provisioned, subsequent deployments of the application can use this certificate name when the
37+
/// <paramref name="certificateName"/> is prompted.</para>
38+
/// <para>For deployments triggered locally by the Azure Developer CLI the <c>config.json</c> file in the <c>.azure/{environment name}</c> path
39+
/// can by modified with the certificate name since Azure Developer CLI will not prompt again for the value.</para>
40+
/// </remarks>
41+
/// <example>
42+
/// This example shows declaring two parameters to capture the custom domain and certificate name and
43+
/// passing them to the <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/>
44+
/// method via the <see cref="AzureContainerAppContainerExtensions.PublishAsAzureContainerApp{T}(IResourceBuilder{T}, Action{AzureResourceInfrastructure, ContainerApp})"/>
45+
/// extension method.
46+
/// <code lang="C#">
47+
/// var builder = DistributedApplication.CreateBuilder();
48+
/// var customDomain = builder.AddParameter("customDomain"); // Value provided at first deployment.
49+
/// var certificateName = builder.AddParameter("certificateName"); // Value provided at second and subsequent deployments.
50+
/// builder.AddProject&lt;Projects.InventoryService&gt;("inventory")
51+
/// .PublishAsAzureContainerApp((module, app) =>
52+
/// {
53+
/// app.ConfigureCustomDomain(customDomain, certificateName);
54+
/// });
55+
/// </code>
56+
/// </example>
57+
[Experimental("ASPIREACADOMAINS001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
58+
public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder<ParameterResource> customDomain, IResourceBuilder<ParameterResource> certificateName)
59+
{
60+
if (app.ParentInfrastructure is not AzureResourceInfrastructure module)
61+
{
62+
throw new ArgumentException("Cannot configure custom domain when resource is not parented by ResourceModuleConstruct.", nameof(app));
63+
}
64+
65+
var containerAppManagedEnvironmentIdParameter = module.GetResources().OfType<ProvisioningParameter>().Single(
66+
p => p.IdentifierName == "outputs_azure_container_apps_environment_id");
67+
var certificatNameParameter = certificateName.AsProvisioningParameter(module);
68+
var customDomainParameter = customDomain.AsProvisioningParameter(module);
69+
70+
var bindingTypeConditional = new ConditionalExpression(
71+
new BinaryExpression(
72+
new IdentifierExpression(certificatNameParameter.IdentifierName),
73+
BinaryOperator.NotEqual,
74+
new StringLiteral(string.Empty)),
75+
new StringLiteral("SniEnabled"),
76+
new StringLiteral("Disabled")
77+
);
78+
79+
var certificateOrEmpty = new ConditionalExpression(
80+
new BinaryExpression(
81+
new IdentifierExpression(certificatNameParameter.IdentifierName),
82+
BinaryOperator.NotEqual,
83+
new StringLiteral(string.Empty)),
84+
new InterpolatedString(
85+
"{0}/managedCertificates/{1}",
86+
[
87+
new IdentifierExpression(containerAppManagedEnvironmentIdParameter.IdentifierName),
88+
new IdentifierExpression(certificatNameParameter.IdentifierName)
89+
]),
90+
new NullLiteral()
91+
);
92+
93+
app.Configuration.Value!.Ingress!.Value!.CustomDomains = new BicepList<ContainerAppCustomDomain>()
94+
{
95+
new ContainerAppCustomDomain()
96+
{
97+
BindingType = bindingTypeConditional,
98+
Name = new IdentifierExpression(customDomainParameter.IdentifierName),
99+
CertificateId = certificateOrEmpty
100+
}
101+
};
102+
}
103+
}

src/Aspire.Hosting.Azure.AppContainers/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ Aspire.Hosting.Azure.AzureContainerAppCustomizationAnnotation.Configure.get -> S
55
Aspire.Hosting.AzureContainerAppContainerExtensions
66
Aspire.Hosting.AzureContainerAppExtensions
77
Aspire.Hosting.AzureContainerAppProjectExtensions
8+
Aspire.Hosting.ContainerAppExtensions
89
static Aspire.Hosting.AzureContainerAppContainerExtensions.PublishAsAzureContainerApp<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>! container, System.Action<Aspire.Hosting.Azure.AzureResourceInfrastructure!, Azure.Provisioning.AppContainers.ContainerApp!>! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>!
910
static Aspire.Hosting.AzureContainerAppExtensions.AddAzureContainerAppsInfrastructure(this Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.IDistributedApplicationBuilder!
1011
static Aspire.Hosting.AzureContainerAppProjectExtensions.PublishAsAzureContainerApp<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>! project, System.Action<Aspire.Hosting.Azure.AzureResourceInfrastructure!, Azure.Provisioning.AppContainers.ContainerApp!>! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>!
12+
static Aspire.Hosting.ContainerAppExtensions.ConfigureCustomDomain(this Azure.Provisioning.AppContainers.ContainerApp! app, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ParameterResource!>! customDomain, Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ParameterResource!>! certificateName) -> void

tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
#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.
5+
46
using System.Runtime.CompilerServices;
57
using Aspire.Hosting.ApplicationModel;
68
using Aspire.Hosting.Utils;
@@ -844,6 +846,122 @@ param outputs_azure_container_apps_environment_id string
844846
Assert.Equal(expectedBicep, bicep);
845847
}
846848

849+
[Fact]
850+
public async Task ConfigureCustomDomainsMutatesIngress()
851+
{
852+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
853+
854+
var customDomain = builder.AddParameter("customDomain");
855+
var certificateName = builder.AddParameter("certificateName");
856+
857+
builder.AddAzureContainerAppsInfrastructure();
858+
builder.AddContainer("api", "myimage")
859+
.WithHttpEndpoint(targetPort: 1111)
860+
.PublishAsAzureContainerApp((module, c) =>
861+
{
862+
c.ConfigureCustomDomain(customDomain, certificateName);
863+
});
864+
865+
using var app = builder.Build();
866+
867+
await ExecuteBeforeStartHooksAsync(app, default);
868+
869+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
870+
871+
var container = Assert.Single(model.GetContainerResources());
872+
873+
container.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var target);
874+
875+
var resource = target?.DeploymentTarget as AzureBicepResource;
876+
877+
Assert.NotNull(resource);
878+
879+
var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource);
880+
881+
var m = manifest.ToString();
882+
883+
var expectedManifest =
884+
"""
885+
{
886+
"type": "azure.bicep.v0",
887+
"path": "api.module.bicep",
888+
"params": {
889+
"outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}",
890+
"outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}",
891+
"outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}",
892+
"certificateName": "{certificateName.value}",
893+
"customDomain": "{customDomain.value}"
894+
}
895+
}
896+
""";
897+
898+
Assert.Equal(expectedManifest, m);
899+
900+
var expectedBicep =
901+
"""
902+
@description('The location for the resource(s) to be deployed.')
903+
param location string = resourceGroup().location
904+
905+
param outputs_azure_container_registry_managed_identity_id string
906+
907+
param outputs_managed_identity_client_id string
908+
909+
param outputs_azure_container_apps_environment_id string
910+
911+
param certificateName string
912+
913+
param customDomain string
914+
915+
resource api 'Microsoft.App/containerApps@2024-03-01' = {
916+
name: 'api'
917+
location: location
918+
properties: {
919+
configuration: {
920+
activeRevisionsMode: 'Single'
921+
ingress: {
922+
external: false
923+
targetPort: 1111
924+
transport: 'http'
925+
customDomains: [
926+
{
927+
name: customDomain
928+
bindingType: (certificateName != '') ? 'SniEnabled' : 'Disabled'
929+
certificateId: (certificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName}' : null
930+
}
931+
]
932+
}
933+
}
934+
environmentId: outputs_azure_container_apps_environment_id
935+
template: {
936+
containers: [
937+
{
938+
image: 'myimage:latest'
939+
name: 'api'
940+
env: [
941+
{
942+
name: 'AZURE_CLIENT_ID'
943+
value: outputs_managed_identity_client_id
944+
}
945+
]
946+
}
947+
]
948+
scale: {
949+
minReplicas: 1
950+
}
951+
}
952+
}
953+
identity: {
954+
type: 'UserAssigned'
955+
userAssignedIdentities: {
956+
'${outputs_azure_container_registry_managed_identity_id}': { }
957+
}
958+
}
959+
}
960+
""";
961+
output.WriteLine(bicep);
962+
Assert.Equal(expectedBicep, bicep);
963+
}
964+
847965
[Fact]
848966
public async Task VolumesAndBindMountsAreTranslation()
849967
{

0 commit comments

Comments
 (0)