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:
And here is the log output from the external container:
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.