Running Ruby on Rails web apps with .NET Aspire
Tags: dotnet, csharp, ruby, rails
Published: 2024-03-18 13:30:00

The Microsoft ecosystem is not kind to Ruby developers. The Ruby SDK for Azure [was retired in February 2021](https://github.com/Azure/azure-sdk-for-ruby), and the support for Ruby in the OpenAPI client generator Kiota [is extremely limited](https://github.com/microsoft/kiota-http-ruby), if not unusable. However, .NET Aspire is a special case. This ambitious local development orchestrator is not tied to any specific technology, as I explained in my [previous article on the inner workings of .NET Aspire](https://anthonysimmon.com/exploring-microsoft-developer-control-plane-core-dotnet-aspire-dotnet-8/). Therefore, it is possible to run Ruby on Rails web applications on it.

> Please note, the source code presented in this article is subject to change, as .NET Aspire is still under development.

## Prerequisites

- [.NET SDK and .NET Aspire must be installed](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/setup-tooling?tabs=dotnet-cli) (latest version at the time of this article: [preview 3](https://learn.microsoft.com/en-us/dotnet/aspire/whats-new/preview-3)),
- [Ruby 3.x](https://www.ruby-lang.org/en/downloads/) (for Windows, use [RubyInstaller](https://rubyinstaller.org/)),
- [Ruby on Rails](https://guides.rubyonrails.org/getting_started.html).

## Developing a custom Rails resource for .NET Aspire's app model

.NET Aspire uses an "app model", which represents the list of resources that make up a distributed application. The two primary resource types in .NET Aspire are executables and containers. For Ruby on Rails web applications, we want to create an executable with the following command:

```shell
ruby bin/rails server
```

This command is used to start a Rails application and is cross-platform. Thanks to the extensibility of the .NET Aspire app model, we can define our own Rails resource, which inherits from the `ExecutableResource` class in .NET Aspire. Here is the complete source code for declaring a Rails application in .NET Aspire:

```cs
using Aspire.Hosting.Lifecycle;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;

namespace Aspire.Hosting;

internal class RailsAppResource(string name, string command, string workingDirectory, string[] args)
    : ExecutableResource(name, "ruby", workingDirectory, ["bin/rails", command, .. args]);

internal static class RailsAppExtensions
{
    public static IResourceBuilder<RailsAppResource> AddRailsApp(
        this IDistributedApplicationBuilder builder, string name, string command, string workingDirectory, string[]? args = null)
    {
        var resource = new RailsAppResource(name, command, workingDirectory, args ?? []);

        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IDistributedApplicationLifecycleHook, RailsAppAddPortLifecycleHook>());

        return builder.AddResource(resource)
            .WithOtlpExporter()
            .WithEnvironment("RAILS_ENV", builder.Environment.IsDevelopment() ? "development" : "production")
            .ExcludeFromManifest();
    }
}

internal sealed class RailsAppAddPortLifecycleHook : IDistributedApplicationLifecycleHook
{
    public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
    {
        var railsApps = appModel.Resources.OfType<RailsAppResource>();

        foreach (var railsApp in railsApps)
        {
            if (railsApp.TryGetEndpoints(out var endpoints))
            {
                var envAnnotation = CreateAddPortEnvironmentCallbackAnnotation(endpoints.ToArray(), railsApp);
                railsApp.Annotations.Add(envAnnotation);
            }
        }

        return Task.CompletedTask;
    }

    private static EnvironmentCallbackAnnotation CreateAddPortEnvironmentCallbackAnnotation(IReadOnlyCollection<EndpointAnnotation> endpoints, IResource app)
    {
        return new EnvironmentCallbackAnnotation(env =>
        {
            var hasManyEndpoints = endpoints.Count > 1;

            if (hasManyEndpoints)
            {
                foreach (var endpoint in endpoints)
                {
                    var serviceName = hasManyEndpoints ? $"{app.Name}_{endpoint.Name}" : app.Name;
                    env[$"PORT_{endpoint.Name.ToUpperInvariant()}"] = $"{{{{- portForServing \"{serviceName}\" -}}}}";
                }
            }
            else
            {
                env["PORT"] = $"{{{{- portForServing \"{app.Name}\" -}}}}";
            }
        });
    }
}
```

Let's focus on the experience of declaring a Rails application in your Program.cs file of your .NET Aspire project:

```cs
var builder = DistributedApplication.CreateBuilder(args);

builder.AddRailsApp("myrailsapp", "server", "path/to/your/rails/app")
    .WithEndpoint(hostPort: 3000, scheme: "http");

builder.Build().Run();
```

The need to create a lifecycle hook for our Rails resources is due to the internal networking operations in .NET Aspire. This internal operation is explained in detail in the official documentation: [.NET Aspire inner-loop networking overview](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/networking-overview#how-service-bindings-work).


## References

- [.NET Aspire documentation (Preview)](https://learn.microsoft.com/en-us/dotnet/aspire/)
- [.NET Aspire inner-loop networking overview](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/networking-overview#how-service-bindings-work)
- [Exploring the Microsoft Developer Control Plane at the heart of the new .NET Aspire](https://anthonysimmon.com/exploring-microsoft-developer-control-plane-core-dotnet-aspire-dotnet-8/)