Featured image of post Referencing external Docker containers in .NET Aspire using the new custom resources API

Referencing external Docker containers in .NET Aspire using the new custom resources API

.NET Aspire reaches a new level of extensibility with preview 5, which allows for the creation of custom resources controlled by your code.

Up until now, the application model of .NET Aspire was limited to two types of resources, namely executables and containers. The underlying DCP (Developer Control Plane) orchestrator is responsible for managing the lifecycle of these resources, including their creation, start, stop, and destruction.

David Fowler recently tweeted about the extensibility of the application model: “.NET Aspire is optimized for developers. With the application model, you can use .NET Code to build up an understanding of your application and its dependencies. This builds an object model that represents a resource graph.

However, .NET Aspire preview 5 changes the status quo by allowing the application model to reach a whole new level of extensibility. Now, it is possible to create custom resources, which can be fully controlled by your C# code and will appear in the dashboard. Among other things, you can:

  • Change their status (starting, running, finished),
  • Declare URLs to access these resources,
  • Add custom properties that can be used in lifecycle hooks,
  • Emit logs that will appear in the dashboard.

There are numerous potential applications for this new extensibility model, and I am eager to see what the community will do with it. For this article, we will add to the dashboard a container that is not controlled by .NET Aspire. One can imagine a development team that has not yet migrated their docker-compose.yml but would still like to see the containers and their logs appear in the .NET Aspire dashboard.

Another motivation would be for a developer to want a Docker container to continue its execution outside of an Aspire context, which is impossible today because Aspire destroys containers at the end of its execution.

Retrieving and displaying external container logs

We will create the following API:

IDistributedApplicationBuilder
    .AddExternalContainer("<resource-name>", "<external-container-name-or-id>");

We will use the command docker logs --follow CONTAINER-NAME-OR-ID to retrieve the logs of the external container. To facilitate the execution of the external docker process, we will use CliWrap instead of the primitive Process.

Here is what the resource will look like in the dashboard:

A MongoDB container is shown in the dashboard but is actually orchestrated by Docker Compose, outside of Aspire

And here is the log output from the external container:

The MongoDB logs are forwarded from Docker to the dashboard

In this example, the external container is a persistent MongoDB instance configured as a single-node replica set as described in my article.

Building the external container custom resource API

In our .NET Aspire app host project, let’s start by adding the definition of our custom resource:

internal sealed class ExternalContainerResource(string name, string containerNameOrId) : Resource(name)
{
    public string ContainerNameOrId { get; } = containerNameOrId;
}

Next, let’s add the AddExternalContainer extension method:

internal static class ExternalContainerResourceExtensions
{
    public static IResourceBuilder<ExternalContainerResource> AddExternalContainer(this IDistributedApplicationBuilder builder, string name, string containerNameOrId)
    {
        builder.Services.TryAddLifecycleHook<ExternalContainerResourceLifecycleHook>();

        return builder.AddResource(new ExternalContainerResource(name, containerNameOrId))
            .WithInitialState(new CustomResourceSnapshot
            {
                ResourceType = "External container",
                State = "Starting",
                Properties = [new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, "Custom")]
            })
            .ExcludeFromManifest();
    }
}

This method will have the effect of adding our resource to the dashboard with the status “Starting” and the source “Custom,” which will allow us to distinguish Aspire-controlled resources from custom resources.

All that remains is to define the ExternalContainerResourceLifecycleHook lifecycle hook, which will be responsible for executing the command docker logs --follow CONTAINER-NAME-OR-ID, changing the resource status, and forwarding the logs to the dashboard:

internal sealed class ExternalContainerResourceLifecycleHook(ResourceNotificationService notificationService, ResourceLoggerService loggerService)
    : IDistributedApplicationLifecycleHook, IAsyncDisposable
{
    private readonly CancellationTokenSource _tokenSource = new();

    public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        foreach (var resource in appModel.Resources.OfType<ExternalContainerResource>())
        {
            this.StartTrackingExternalContainerLogs(resource, this._tokenSource.Token);
        }

        return Task.CompletedTask;
    }

    private void StartTrackingExternalContainerLogs(ExternalContainerResource resource, CancellationToken cancellationToken)
    {
        var logger = loggerService.GetLogger(resource);

        _ = Task.Run(async () =>
        {
            var cmd = Cli.Wrap("docker").WithArguments(["logs", "--follow", resource.ContainerNameOrId]);
            var cmdEvents = cmd.ListenAsync(cancellationToken);

            await foreach (var cmdEvent in cmdEvents)
            {
                switch (cmdEvent)
                {
                    case StartedCommandEvent:
                        await notificationService.PublishUpdateAsync(resource, state => state with { State = "Running" });
                        break;
                    case ExitedCommandEvent:
                        await notificationService.PublishUpdateAsync(resource, state => state with { State = "Finished" });
                        break;
                    case StandardOutputCommandEvent stdOut:
                        logger.LogInformation("External container {ResourceName} stdout: {StdOut}", resource.Name, stdOut.Text);
                        break;
                    case StandardErrorCommandEvent stdErr:
                        logger.LogInformation("External container {ResourceName} stderr: {StdErr}", resource.Name, stdErr.Text);
                        break;
                }
            }
        }, cancellationToken);
    }

    public ValueTask DisposeAsync()
    {
        this._tokenSource.Cancel();
        return default;
    }
}

Conclusion

This evolution of the .NET Aspire application model in this preview 5 opens new perspectives for developers. Combined with your own lifecycle hooks, flexibility and extensibility are greater than ever. The introduction of the new method IDistributedApplicationLifecycleHook.AfterEndpointsAllocatedAsync following the resolution of the GitHub issue #2155 I opened last February allows you to execute arbitrary code during a more advanced phase of the Aspire host lifecycle.

References

Licensed under CC BY 4.0
Ko-fi donations Buy me a coffee