Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental custom domain support for ACA. #6275

Merged
merged 4 commits into from
Oct 21, 2024
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
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
davidfowl marked this conversation as resolved.
Show resolved Hide resolved
{
/// <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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this part.

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>()
mitchdenny marked this conversation as resolved.
Show resolved Hide resolved
{
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