Running Ruby on Rails web apps with .NET Aspire

asimmon

Anthony Simmon

Posted on March 18, 2024

Running Ruby on Rails web apps with .NET Aspire

The Microsoft ecosystem is not kind to Ruby developers. The Ruby SDK for Azure was retired in February 2021, and the support for Ruby in the OpenAPI client generator Kiota is extremely limited, 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. 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

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:

ruby bin/rails server
Enter fullscreen mode Exit fullscreen mode

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:

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}\" -}}}}";
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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

var builder = DistributedApplication.CreateBuilder(args);

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

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

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.

References

💖 💪 🙅 🚩
asimmon
Anthony Simmon

Posted on March 18, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related