Skip to content

Commit 5019a68

Browse files
Support multiple domains via ConfigureCustomDomain (#7309)
* Add or update custom domains * Fixing tests Fixing tests (some work arounds for the implicit operators in CDK). --------- Co-authored-by: Mitch Denny <[email protected]>
1 parent d6ae0ba commit 5019a68

File tree

2 files changed

+283
-12
lines changed

2 files changed

+283
-12
lines changed

src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public static class ContainerAppExtensions
2020
/// </summary>
2121
/// <param name="app">The container app resource to configure for custom domain usage.</param>
2222
/// <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>
23+
/// <param name="certificateName">A resource builder for a parameter resource capturing the name of the certificate configured in the Azure Portal.</param>
2424
/// <exception cref="ArgumentException">Throws if the container app resource is not parented to a <see cref="AzureResourceInfrastructure"/>.</exception>
2525
/// <remarks>
2626
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> extension method
@@ -32,7 +32,7 @@ public static class ContainerAppExtensions
3232
/// two arguments which are parameter resource builders. The first is a parameter that represents the custom domain and the second is a parameter that
3333
/// represents the name of the managed certificate provisioned via the Azure Portal</para>
3434
/// <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
35+
/// 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
3636
/// certificate. Once the certificate is successfully provisioned, subsequent deployments of the application can use this certificate name when the
3737
/// <paramref name="certificateName"/> is prompted.</para>
3838
/// <para>For deployments triggered locally by the Azure Developer CLI the <c>config.json</c> file in the <c>.azure/{environment name}</c> path
@@ -90,14 +90,33 @@ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder
9090
new NullLiteralExpression()
9191
);
9292

93-
app.Configuration.Ingress.CustomDomains = new BicepList<ContainerAppCustomDomain>()
94-
{
95-
new ContainerAppCustomDomain()
96-
{
97-
BindingType = bindingTypeConditional,
98-
Name = customDomainParameter,
99-
CertificateId = certificateOrEmpty
100-
}
101-
};
93+
var containerAppCustomDomain = new ContainerAppCustomDomain()
94+
{
95+
BindingType = bindingTypeConditional,
96+
Name = customDomainParameter,
97+
CertificateId = certificateOrEmpty
98+
};
99+
100+
var existingCustomDomain = app.Configuration.Ingress.CustomDomains
101+
.FirstOrDefault(cd => {
102+
// This is a cautionary tale to anyone who reads this code as to the dangers
103+
// of using implicit conversions in C#. BicepValue<T> uses some implicit conversions
104+
// which means we need to explicitly cast to IBicepValue so that we can get at the
105+
// source construct behind the Bicep value on the "name" field for a custom domain
106+
// in the Bicep. If the constructs are the same ProvisioningParameter then we have a
107+
// match - otherwise we are possibly dealing with a second domain. This deals with the
108+
// edge case of where someone might call ConfigureCustomDomain multiple times on the
109+
// same domain - unlikely but possible if someone has built some libraries.
110+
var itemDomainNameBicepValue = cd.Value?.Name as IBicepValue;
111+
var candidateDomainNameBicepValue = containerAppCustomDomain.Name as IBicepValue;
112+
return itemDomainNameBicepValue?.Source?.Construct == candidateDomainNameBicepValue.Source?.Construct;
113+
});
114+
115+
if (existingCustomDomain is not null)
116+
{
117+
app.Configuration.Ingress.CustomDomains.Remove(existingCustomDomain);
118+
}
119+
120+
app.Configuration.Ingress.CustomDomains.Add(containerAppCustomDomain);
102121
}
103122
}

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

Lines changed: 253 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1169,7 +1169,7 @@ param outputs_azure_container_apps_environment_id string
11691169
}
11701170

11711171
[Fact]
1172-
public async Task ConfigureCustomDomainsMutatesIngress()
1172+
public async Task ConfigureCustomDomainMutatesIngress()
11731173
{
11741174
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
11751175

@@ -1284,6 +1284,258 @@ param customDomain string
12841284
Assert.Equal(expectedBicep, bicep);
12851285
}
12861286

1287+
[Fact]
1288+
public async Task ConfigureDuplicateCustomDomainMutatesIngress()
1289+
{
1290+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
1291+
1292+
var customDomain = builder.AddParameter("customDomain");
1293+
var initialCertificateName = builder.AddParameter("initialCertificateName");
1294+
var expectedCertificateName = builder.AddParameter("expectedCertificateName");
1295+
1296+
builder.AddAzureContainerAppsInfrastructure();
1297+
builder.AddContainer("api", "myimage")
1298+
.WithHttpEndpoint(targetPort: 1111)
1299+
.PublishAsAzureContainerApp((module, c) =>
1300+
{
1301+
c.ConfigureCustomDomain(customDomain, initialCertificateName);
1302+
c.ConfigureCustomDomain(customDomain, expectedCertificateName);
1303+
});
1304+
1305+
using var app = builder.Build();
1306+
1307+
await ExecuteBeforeStartHooksAsync(app, default);
1308+
1309+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
1310+
1311+
var container = Assert.Single(model.GetContainerResources());
1312+
1313+
container.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var target);
1314+
1315+
var resource = target?.DeploymentTarget as AzureBicepResource;
1316+
1317+
Assert.NotNull(resource);
1318+
1319+
var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource);
1320+
1321+
var m = manifest.ToString();
1322+
1323+
var expectedManifest =
1324+
"""
1325+
{
1326+
"type": "azure.bicep.v0",
1327+
"path": "api.module.bicep",
1328+
"params": {
1329+
"outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}",
1330+
"outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}",
1331+
"outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}",
1332+
"initialCertificateName": "{initialCertificateName.value}",
1333+
"customDomain": "{customDomain.value}",
1334+
"expectedCertificateName": "{expectedCertificateName.value}"
1335+
}
1336+
}
1337+
""";
1338+
1339+
Assert.Equal(expectedManifest, m);
1340+
1341+
var expectedBicep =
1342+
"""
1343+
@description('The location for the resource(s) to be deployed.')
1344+
param location string = resourceGroup().location
1345+
1346+
param outputs_azure_container_registry_managed_identity_id string
1347+
1348+
param outputs_managed_identity_client_id string
1349+
1350+
param outputs_azure_container_apps_environment_id string
1351+
1352+
param initialCertificateName string
1353+
1354+
param customDomain string
1355+
1356+
param expectedCertificateName string
1357+
1358+
resource api 'Microsoft.App/containerApps@2024-03-01' = {
1359+
name: 'api'
1360+
location: location
1361+
properties: {
1362+
configuration: {
1363+
activeRevisionsMode: 'Single'
1364+
ingress: {
1365+
external: false
1366+
targetPort: 1111
1367+
transport: 'http'
1368+
customDomains: [
1369+
{
1370+
name: customDomain
1371+
bindingType: (expectedCertificateName != '') ? 'SniEnabled' : 'Disabled'
1372+
certificateId: (expectedCertificateName != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${expectedCertificateName}' : null
1373+
}
1374+
]
1375+
}
1376+
}
1377+
environmentId: outputs_azure_container_apps_environment_id
1378+
template: {
1379+
containers: [
1380+
{
1381+
image: 'myimage:latest'
1382+
name: 'api'
1383+
env: [
1384+
{
1385+
name: 'AZURE_CLIENT_ID'
1386+
value: outputs_managed_identity_client_id
1387+
}
1388+
]
1389+
}
1390+
]
1391+
scale: {
1392+
minReplicas: 1
1393+
}
1394+
}
1395+
}
1396+
identity: {
1397+
type: 'UserAssigned'
1398+
userAssignedIdentities: {
1399+
'${outputs_azure_container_registry_managed_identity_id}': { }
1400+
}
1401+
}
1402+
}
1403+
""";
1404+
output.WriteLine(bicep);
1405+
Assert.Equal(expectedBicep, bicep);
1406+
}
1407+
1408+
[Fact]
1409+
public async Task ConfigureMultipleCustomDomainsMutatesIngress()
1410+
{
1411+
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
1412+
1413+
var customDomain1 = builder.AddParameter("customDomain1");
1414+
var certificateName1 = builder.AddParameter("certificateName1");
1415+
1416+
var customDomain2 = builder.AddParameter("customDomain2");
1417+
var certificateName2 = builder.AddParameter("certificateName2");
1418+
1419+
builder.AddAzureContainerAppsInfrastructure();
1420+
builder.AddContainer("api", "myimage")
1421+
.WithHttpEndpoint(targetPort: 1111)
1422+
.PublishAsAzureContainerApp((module, c) =>
1423+
{
1424+
c.ConfigureCustomDomain(customDomain1, certificateName1);
1425+
c.ConfigureCustomDomain(customDomain2, certificateName2);
1426+
});
1427+
1428+
using var app = builder.Build();
1429+
1430+
await ExecuteBeforeStartHooksAsync(app, default);
1431+
1432+
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
1433+
1434+
var container = Assert.Single(model.GetContainerResources());
1435+
1436+
container.TryGetLastAnnotation<DeploymentTargetAnnotation>(out var target);
1437+
1438+
var resource = target?.DeploymentTarget as AzureBicepResource;
1439+
1440+
Assert.NotNull(resource);
1441+
1442+
var (manifest, bicep) = await ManifestUtils.GetManifestWithBicep(resource);
1443+
1444+
var m = manifest.ToString();
1445+
1446+
var expectedManifest =
1447+
"""
1448+
{
1449+
"type": "azure.bicep.v0",
1450+
"path": "api.module.bicep",
1451+
"params": {
1452+
"outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}",
1453+
"outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}",
1454+
"outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}",
1455+
"certificateName1": "{certificateName1.value}",
1456+
"customDomain1": "{customDomain1.value}",
1457+
"certificateName2": "{certificateName2.value}",
1458+
"customDomain2": "{customDomain2.value}"
1459+
}
1460+
}
1461+
""";
1462+
1463+
Assert.Equal(expectedManifest, m);
1464+
1465+
var expectedBicep =
1466+
"""
1467+
@description('The location for the resource(s) to be deployed.')
1468+
param location string = resourceGroup().location
1469+
1470+
param outputs_azure_container_registry_managed_identity_id string
1471+
1472+
param outputs_managed_identity_client_id string
1473+
1474+
param outputs_azure_container_apps_environment_id string
1475+
1476+
param certificateName1 string
1477+
1478+
param customDomain1 string
1479+
1480+
param certificateName2 string
1481+
1482+
param customDomain2 string
1483+
1484+
resource api 'Microsoft.App/containerApps@2024-03-01' = {
1485+
name: 'api'
1486+
location: location
1487+
properties: {
1488+
configuration: {
1489+
activeRevisionsMode: 'Single'
1490+
ingress: {
1491+
external: false
1492+
targetPort: 1111
1493+
transport: 'http'
1494+
customDomains: [
1495+
{
1496+
name: customDomain1
1497+
bindingType: (certificateName1 != '') ? 'SniEnabled' : 'Disabled'
1498+
certificateId: (certificateName1 != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName1}' : null
1499+
}
1500+
{
1501+
name: customDomain2
1502+
bindingType: (certificateName2 != '') ? 'SniEnabled' : 'Disabled'
1503+
certificateId: (certificateName2 != '') ? '${outputs_azure_container_apps_environment_id}/managedCertificates/${certificateName2}' : null
1504+
}
1505+
]
1506+
}
1507+
}
1508+
environmentId: outputs_azure_container_apps_environment_id
1509+
template: {
1510+
containers: [
1511+
{
1512+
image: 'myimage:latest'
1513+
name: 'api'
1514+
env: [
1515+
{
1516+
name: 'AZURE_CLIENT_ID'
1517+
value: outputs_managed_identity_client_id
1518+
}
1519+
]
1520+
}
1521+
]
1522+
scale: {
1523+
minReplicas: 1
1524+
}
1525+
}
1526+
}
1527+
identity: {
1528+
type: 'UserAssigned'
1529+
userAssignedIdentities: {
1530+
'${outputs_azure_container_registry_managed_identity_id}': { }
1531+
}
1532+
}
1533+
}
1534+
""";
1535+
output.WriteLine(bicep);
1536+
Assert.Equal(expectedBicep, bicep);
1537+
}
1538+
12871539
[Fact]
12881540
public async Task VolumesAndBindMountsAreTranslation()
12891541
{

0 commit comments

Comments
 (0)