What are Custom Integrations in .NET Aspire?

In the earlier blog post, I showed how to configure a Curity Identity Server container in a .NET Aspire solution. In this blog post, I'll continue refactoring the Curity configuration to be even more straightforward and reusable. I'll introduce you to the .NET Aspire concept called Custom Integrations, and especially to hosting integrations.

What are Custom Integrations in .NET Aspire, and why should you consider them?

Custom integrations in .NET Aspire allow developers to enhance the application model by creating reusable and easily shareable components. The purpose of this sample is to demonstrate how to create custom hosting integration for Curity Identity Server.

Overall, you can think about the .NET Aspire Application model as a microkernel architecture, and integrations are plugins that are attached to the microkernel.

At the moment (02/25), there are around 20 publicly available integration components. You can find integrations for Keycloak, Apache Kaftka, Elasticsearch, etc.

As said, the main purpose of custom integrations is to boost shareability and make configuration easier. Custom integration concept enables that some part of the "internal complexity" can be hided from the custom integration consumers. In the previous blog post, consumers needed to know e.g. which are container internal target paths for license file and configuration. To make integration more straightforward these kinds of details can be hidden.

Let's get started

1. Create a new class library project

  • Install Aspire.Hosting Nuget Package to the new project

2. Create a Curity Constant class

This class is used to centralize all necessary constants in a single file. In the previous sample, these kinds of configurations were mostly directly in the AppHost project.

Custom integration consumers don't need to know e.g. which are the internal target paths to license and config files.

internal static class CurityContainerConstants
{
    internal static class TargetPaths
    {
        public const string LicenseFilePath = "/opt/idsvr/etc/init/license/license.json";
        public const string ConfigFilePath = "/opt/idsvr/etc/init/curity-config.xml";
    }
    internal static class EnvironmentVariableNames
    {
        public const string AdminUserPassword = "PASSWORD";
    }

    internal static class ImageSource
    {
        public const string Registry = "curity.azurecr.io";
        public const string Image = "curity/idsvr";
        public const string Tag = "latest";
    }

    internal static class DefaultPorts
    {
        public const int AuthorizationServer = 8443;
        public const string AuthorizationServerPortName = "authorization";
        public const int ManagementInterface = 6749;
        public const string ManagementInterfacePortName = "admin";
    }
}

3. Create a Curity Resource class

The Resource class needs to implement the IResource interface. I'll keep it as simple as possible.

public sealed class CurityResource(string name) : ContainerResource(name), IResourceWithServiceDiscovery
{

}

3. Create a Curity extension class

As said in the beginning, we want the use of the custom integration to be as easy as possible. To enable this we will create an extension class which extends the IDistributedApplicationBuilder interface.

The extension class has a method called AddCurity that sets up configurations like I showed in my last blog post. Beauty of this solution is that now some internal complexities can be hidden.

public static class CurityResourceBuilderExtensions
{
    public static IResourceBuilder<CurityResource> AddCurity(
        this IDistributedApplicationBuilder builder,
        string resourceName,
        IResourceBuilder<ParameterResource>? adminPassword = null)
        {
            return AddCurity(builder, resourceName, CurityContainerConstants.DefaultPorts.AuthorizationServer, CurityContainerConstants.DefaultPorts.ManagementInterface, adminPassword);
        }

    public static IResourceBuilder<CurityResource> AddCurity(
        this IDistributedApplicationBuilder builder,
        string resourceName,
        int? authorizationServerPort,
        int? managementServerPort,
        IResourceBuilder<ParameterResource>? adminPassword = null)
        {
            ArgumentNullException.ThrowIfNull(builder);
            ArgumentNullException.ThrowIfNull(resourceName);
            ArgumentNullException.ThrowIfNull(adminPassword);

            var resource = new CurityResource(resourceName);

            var curity = builder
                .AddResource(resource)
                .WithImage(CurityContainerConstants.ImageSource.Image)
                .WithImageRegistry(CurityContainerConstants.ImageSource.Registry)
                .WithImageTag(CurityContainerConstants.ImageSource.Tag)
                .WithHttpEndpoint(port: authorizationServerPort, targetPort: CurityContainerConstants.DefaultPorts.AuthorizationServer, name: CurityContainerConstants.DefaultPorts.AuthorizationServerPortName)
                .WithHttpEndpoint(port: managementServerPort, targetPort: CurityContainerConstants.DefaultPorts.ManagementInterface, name: CurityContainerConstants.DefaultPorts.ManagementInterfacePortName)
                .WithEnvironment(context =>
                {
                    context.EnvironmentVariables[CurityContainerConstants.EnvironmentVariableNames.AdminUserPassword] = adminPassword?.Resource;
                });

            return curity;
        }

    public static IResourceBuilder<CurityResource> WithLicenseDataBindMount(this IResourceBuilder<CurityResource> builder, string licenseFileSourcePath)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(licenseFileSourcePath);
        return builder.WithBindMount(licenseFileSourcePath, CurityContainerConstants.TargetPaths.LicenseFilePath, false).WithLifetime(ContainerLifetime.Persistent);
    }

    public static IResourceBuilder<CurityResource> WithConfigDataBindMount(this IResourceBuilder<CurityResource> builder, string configFileSourcePath)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(configFileSourcePath);
        return builder.WithBindMount(configFileSourcePath, CurityContainerConstants.TargetPaths.ConfigFilePath, false).WithLifetime(ContainerLifetime.Persistent);
    }
}

5. Create a Nuget package

  • Configure and publish the project as a Nuget package.

6. Consume the Curity custom integration Nuget package

After adding a Nuget-package to the AppHost project, we can now call AddCurity to incorporate the Curity Identity Server container into the solution.

var curityAdminPassword = builder.AddParameter("curityAdminPassword", secret: true);
var licenseFileSourcePath = @"C:\Curity\Data\license.json";
var configFileSourcePath = @"C:\Curity\Data\curity-config.xml";

builder.AddCurity("curity", curityAdminPassword)
    .WithLicenseDataBindMount(licenseFileSourcePath)
    .WithConfigDataBindMount(configFileSourcePath);

Original configuration in the previous blog post was this for a comparison:

var curityAdminPassword = builder.AddParameter("curityAdminPassword", secret: true);
var adminUiPort = 6749;
var authorizationServerPort = 8443;

var curity = builder.AddContainer("curity", "curity.azurecr.io/curity/idsvr")
    .WithEndpoint(adminUiPort, adminUiPort, name: "adminui", scheme: "https")
    .WithEndpoint(authorizationServerPort, authorizationServerPort, name: "authorizationserver", scheme: "https")
    .WithBindMount(@"C:\Curity\Data\license.json", "/opt/idsvr/etc/init/license/license.json").WithLifetime(ContainerLifetime.Persistent)
    .WithEnvironment("PASSWORD", curityAdminPassword.Resource.Value);

Summary

Overall, there is nothing special when it comes to building custom integrations. Creating a custom hosting integration is easy and straighforward. Microsoft has also created a good article about creating these customer hosting and client integrations.

Comments