Skip to content

Commit

Permalink
Experimental custom domain support for ACA. (#6275)
Browse files Browse the repository at this point in the history
* ConfigureCustomDomain extension method.
  • Loading branch information
mitchdenny authored Oct 21, 2024
1 parent 9e815f4 commit 7fa8a02
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -28,6 +34,8 @@
.WithEnvironment("VALUE", param)
.PublishAsAzureContainerApp((module, app) =>
{
app.ConfigureCustomDomain(customDomain, certificateName);

// Scale to 0
app.Template.Value!.Scale.Value!.MinReplicas = 0;
});
Expand All @@ -43,4 +51,3 @@
#endif

builder.Build().Run();

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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: [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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}",
Expand Down Expand Up @@ -32,7 +50,7 @@
],
"volumes": [
{
"name": "azurecontainerapps.apphost-b5fb0098a7-cache-data",
"name": "azurecontainerapps.apphost-43a728061e-cache-data",
"target": "/data",
"readOnly": false
}
Expand Down Expand Up @@ -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}",
Expand Down
103 changes: 103 additions & 0 deletions src/Aspire.Hosting.Azure.AppContainers/ContainerAppExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides extension methods for customizing Azure Container App resource.
/// </summary>
public static class ContainerAppExtensions
{
/// <summary>
/// Configures the custom domain for the container app.
/// </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>
/// <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
/// 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>
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> method is used
/// in conjunction with the <see cref="AzureContainerAppContainerExtensions.PublishAsAzureContainerApp{T}(IResourceBuilder{T}, Action{AzureResourceInfrastructure, ContainerApp})"/>
/// callback. Assigning a custom domain to a container app resource is a multi-step process and requires multiple deployments.</para>
/// <para>The <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/> 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</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
/// 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
/// can by modified with the certificate name since Azure Developer CLI will not prompt again for the value.</para>
/// </remarks>
/// <example>
/// This example shows declaring two parameters to capture the custom domain and certificate name and
/// passing them to the <see cref="ConfigureCustomDomain(ContainerApp, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/>
/// method via the <see cref="AzureContainerAppContainerExtensions.PublishAsAzureContainerApp{T}(IResourceBuilder{T}, Action{AzureResourceInfrastructure, ContainerApp})"/>
/// extension method.
/// <code lang="C#">
/// 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&lt;Projects.InventoryService&gt;("inventory")
/// .PublishAsAzureContainerApp((module, app) =>
/// {
/// app.ConfigureCustomDomain(customDomain, certificateName);
/// });
/// </code>
/// </example>
[Experimental("ASPIREACADOMAINS001", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")]
public static void ConfigureCustomDomain(this ContainerApp app, IResourceBuilder<ParameterResource> customDomain, IResourceBuilder<ParameterResource> 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<ProvisioningParameter>().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<ContainerAppCustomDomain>()
{
new ContainerAppCustomDomain()
{
BindingType = bindingTypeConditional,
Name = new IdentifierExpression(customDomainParameter.IdentifierName),
CertificateId = certificateOrEmpty
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(this Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>! container, System.Action<Aspire.Hosting.Azure.AzureResourceInfrastructure!, Azure.Provisioning.AppContainers.ContainerApp!>! configure) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<T!>!
static Aspire.Hosting.AzureContainerAppExtensions.AddAzureContainerAppsInfrastructure(this Aspire.Hosting.IDistributedApplicationBuilder! builder) -> Aspire.Hosting.IDistributedApplicationBuilder!
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!>!
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
118 changes: 118 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<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}",
"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()
{
Expand Down

0 comments on commit 7fa8a02

Please sign in to comment.