Integrating the Particular Service Platform with Aspire

I've been playing around with Aspire for a bit mainly to understand "is this a thing I should care about?" and part of what I wanted to do is take a complex "hello world" distributed system and convert it to Aspire. Along the way, Particular Software also released container support for their Service Platform, so it also seemed like a good opportunity to try it out.

I'll follow up in another post about Aspire impressions, but the NServiceBus part was actually relatively simple. Many Aspire integrations have some kind of 1st-party support where you can do things like:

var rmqPassword = builder.AddParameter("messaging-password");
var dbPassword = builder.AddParameter("db-password");

var broker = builder.AddRabbitMQ(name: "broker", password: rmqPassword, port: 5672)
    .WithDataVolume()
    .WithManagementPlugin()
    .WithEndpoint("management", e => e.Port = 15672)
    .WithHealthCheck();
var mongo = builder.AddMongoDB("mongo");
var sql = builder.AddSqlServer("sql", password: dbPassword)
    .WithHealthCheck()
    .WithDataVolume()
    .AddDatabase("sqldata");

And now my system has RabbitMQ, MongoDB, and SQL Server up and running in containers. There's a lot of stock configuration going on behind AddSqlServer and similar methods but we don't have to use those convenience methods if we don't want to.

The overall Service Platform architecture looks something like:

The "instances" here are running containers that we need to configure in Aspire. On top of that, we might also want to have Service Pulse (another container) and Service Insight (a Windows-only WPF app) running, and these all require extra configuration. Also, the Error and Audit instances use RavenDB as their backing store but Particular also has an image there. The Docker Hub site has links to docs on both the instance and containers.

First up, we need to provide our license to the running containers as raw text in an environment variable, so we'll just read our license (this is just for local development):

var license = File.ReadAllText(                                              
    Path.Combine(                                                            
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
        "ParticularSoftware",                                                
        "license.xml"));                                                     

Next, we need our RavenDB instance. There's a special image from Particular, so we'll use the AddContainer method to add our custom image to our Aspire distributed application:

builder                                                                             
    .AddContainer("servicecontroldb", "particular/servicecontrol-ravendb", "latest")
    .WithBindMount("AppHost-servicecontroldb-data", "/opt/RavenDB/Server/RavenData")
    .WithEndpoint(8080, 8080);                                                      

The container docs say that we must mount a persistent volume to that path, so we use the WithBindMount method to mount the volume following the Aspire docs.

Next up are the Particular containers!

Setting up the Service Control Error instance

From the Particular docs, we see that we need to supply configuration for:

  • Transport type (RabbitMQ, Azure Service Bus, etc.)
  • Connection string to the transport
  • Connection string to the Raven DB instance
  • Audit instance URLs
  • License

Plus port mapping. Pretty quickly I ran into a few challenges:

  • The Service Control image can start before RabbitMQ is "ready", resulting in connection failures
  • Service Insight, the WPF app, is Windows only so I need to connect to Service Control from a VM

The base configuration is fairly straightforward, we specify the container and image, with environment variables:

builder                                                                                             
    .AddContainer("servicecontrol", "particular/servicecontrol")                                    
    .WithEnvironment("TransportType", "RabbitMQ.QuorumConventionalRouting")                         
    .WithEnvironment("ConnectionString", "host=host.docker.internal")                               
    .WithEnvironment("RavenDB_ConnectionString", "http://host.docker.internal:8080")                
    .WithEnvironment("RemoteInstances", "[{\"api_uri\":\"http://host.docker.internal:44444/api\"}]")
    .WithEnvironment("PARTICULARSOFTWARE_LICENSE", license)                                         
    .WithArgs("--setup-and-run")                                                                    

But the other two challenges are a bit harder to deal with. There is no built-in way in Aspire to "wait" for other resources to start. This isn't new to Aspire - in the past we had to write custom hooks in Docker Compose to wait for our dependencies' health checks to come back. The extensibility is there to do such a thing, so I found an extension to do just that.

The second problem was...a long slog to figure out. It's possible to have a Parallels VM be able to communicate with Docker containers running in the Mac host. However, I could not get this to work with Aspire. After doing side-by-side comparisons between container manifests running inside/outside of Aspire, I found the culprit:

"PortBindings": {
	"8080/tcp": [
		{
-			"HostIp": "",
+			"HostIp": "127.0.0.1",
			"HostPort": "8000"
		}
	]
},

With the Docker CLI, doing -p 8080:8000 does not set the host IP. Aspire does however, which means I can only access this container via localhost. Not ideal because my Windows VM is definitely not able to access that. Instead of using WithEndpoint or similar, I have to drop down to container runtime args:

.WithContainerRuntimeArgs("-p", "33333:33333")
.WaitFor(rabbitMqResource);                   

Now my Service Control instance is up and running!

Setting up Service Control Audit, Monitoring, and Service Pulse

Following our previous example, we can finish out our configuration for the other container instances:

builder                                                                              
    .AddContainer("servicecontrolaudit", "particular/servicecontrol-audit")          
    .WithEnvironment("TransportType", "RabbitMQ.QuorumConventionalRouting")          
    .WithEnvironment("ConnectionString", "host=host.docker.internal")                
    .WithEnvironment("RavenDB_ConnectionString", "http://host.docker.internal:8080") 
    .WithEnvironment("PARTICULARSOFTWARE_LICENSE", license)                          
    .WithArgs("--setup-and-run")                                                     
    .WithEndpoint(44444, 44444)                                                      
    .WaitFor(rabbitMqResource);                                                      
                                                                                     
builder                                                                              
    .AddContainer("servicecontrolmonitoring", "particular/servicecontrol-monitoring")
    .WithEnvironment("TransportType", "RabbitMQ.QuorumConventionalRouting")          
    .WithEnvironment("ConnectionString", "host=host.docker.internal")                
    .WithEnvironment("PARTICULARSOFTWARE_LICENSE", license)                          
    .WithArgs("--setup-and-run")                                                     
    .WithEndpoint(33633, 33633)                                                      
    .WaitFor(rabbitMqResource);                                                      
                                                                                     
builder                                                                              
    .AddContainer("servicepulse", "particular/servicepulse")                         
    .WithEnvironment("SERVICECONTROL_URL", "http://host.docker.internal:33333")      
    .WithEnvironment("MONITORING_URL", "http://host.docker.internal:33633")          
    .WithEnvironment("PARTICULARSOFTWARE_LICENSE", license)                          
    .WithEndpoint(9090, 9090)                                                        
    .WaitFor(rabbitMqResource);                                                      

With all this in place in my Service Pulse instance is up and running:

And on the Service Insight side, I had to do the Parallels trick of using my hosts file to create a special "localhost.mac" entry to point to the Mac host:

10.211.55.2 localhost.mac

With this in place, I can configure Service Insight in Windows to connect to the Docker Service Pulse instance running in Docker on the Mac:

All my NServiceBus messages and traces now show up just fine:

Most of the work I had to do was not really Aspire-related, but just configuring Aspire to pass in the appropriate configuration to the containers. You can find the full code to my configuration here:

Code Example

Enjoy!