Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public static class ContainerAppExtensions
/// </summary>
/// <param name="app">The container app resource to configure for custom domain usage.</param>
/// <param name="customDomain">A resource builder for a parameter resource capturing the name of the custom domain.</param>
/// <param name="certificateName">A resource builder for a parameter resource capturing the name of the certficate configured in the Azure Portal.</param>
/// <param name="certificateName">A resource builder for a parameter resource capturing the name of the certificate configured in the Azure Portal.</param>
/// <exception cref="ArgumentException">Throws if the container app resource is not parented to a <see cref="AzureResourceInfrastructure"/>.</exception>
/// <remarks>
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> extension method
Expand All @@ -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</para>
/// <para>When deploying with custom domains configured for the first time leave the <paramref name="certificateName"/> 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
/// <paramref name="certificateName"/> is prompted.</para>
/// <para>For deployments triggered locally by the Azure Developer CLI the <c>config.json</c> file in the <c>.azure/{environment name}</c> path
Expand Down Expand Up @@ -90,14 +90,33 @@ public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder
new NullLiteralExpression()
);

app.Configuration.Ingress.CustomDomains = new BicepList<ContainerAppCustomDomain>()
{
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 => {
// This is a cautionary tale to anyone who reads this code as to the dangers
// of using implicit conversions in C#. BicepValue<T> 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)
{
app.Configuration.Ingress.CustomDomains.Remove(existingCustomDomain);
}

app.Configuration.Ingress.CustomDomains.Add(containerAppCustomDomain);
}
}
254 changes: 253 additions & 1 deletion tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -1176,6 +1176,258 @@ 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<DistributedApplicationModel>();

var container = Assert.Single(model.GetContainerResources());

container.TryGetLastAnnotation<DeploymentTargetAnnotation>(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}",
"initialCertificateName": "{initialCertificateName.value}",
"customDomain": "{customDomain.value}",
"expectedCertificateName": "{expectedCertificateName.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 initialCertificateName string

param customDomain string

param expectedCertificateName 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<DistributedApplicationModel>();

var container = Assert.Single(model.GetContainerResources());

container.TryGetLastAnnotation<DeploymentTargetAnnotation>(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()
{
Expand Down