Files
server/AppHost/BuilderExtensions.cs
Stephon Brown 4dece669ea Migrate Web Frontend to Official Aspire JavaScript Hosting (#7731)
* chore(deps): remove Node.js community plugin

* refactor(AppHost): integrate web frontend with Aspire JS hosting

* docs(AppHost): update web frontend and Aspire documentation

* feat(AppHost): configure Azurite emulator with persistent data volume

* docs(AppHost): fix ClientsPath description to reference worktrees section

* fix(Apphost): run dotnet format
2026-05-28 13:33:47 -04:00

303 lines
16 KiB
C#

using Aspire.Hosting.Azure;
using Aspire.Hosting.JavaScript;
using Azure.Provisioning;
using Azure.Provisioning.Storage;
namespace Bit.AppHost;
public static class BuilderExtensions
{
/// <summary>
/// Configures the secrets setup executable resource.
/// </summary>
/// <param name="builder">The distributed application builder used to configure the secrets setup resource.</param>
/// <returns>>The configured resource builder for the secrets setup executable.</returns>
public static IResourceBuilder<ExecutableResource> ConfigureSecrets(this IDistributedApplicationBuilder builder)
{
return builder
.AddExecutable("setup-secrets", "pwsh", "../dev", "-File", builder.Required("Scripts:SecretsSetup"),
"-clear")
.ExcludeFromManifest();
}
/// <summary>
/// Configures the migrations executable resource.
/// </summary>
/// <param name="builder">The distributed application builder used to configure the migrations resource.</param>
/// <returns>>The configured resource builder for the migrations executable.</returns>
public static IResourceBuilder<ExecutableResource> ConfigureMigrations(this IDistributedApplicationBuilder builder)
{
var migrationArgs = new List<string> { "-File", builder.Required("Scripts:DbMigration") };
if (builder.IsSelfHosted())
migrationArgs.Add("-self-hosted");
return builder
.AddExecutable("run-db-migrations", "pwsh", builder.Required("WorkingDirectory"), migrationArgs.ToArray());
}
public static IResourceBuilder<SqlServerDatabaseResource> AddSqlServerDatabaseResource(
this IDistributedApplicationBuilder builder)
{
var isSelfHosted = builder.IsSelfHosted();
var passwordKey = isSelfHosted ? "Database:SelfHostPassword" : "Database:Password";
if (!int.TryParse(builder.Required("Database:Port"), out var dbPort))
throw new InvalidOperationException("Invalid value for Database:Port.");
var dbPassword = builder.AddParameter("dbPassword", builder.Required(passwordKey), secret: true);
return builder
.AddSqlServer("mssql", password: dbPassword, dbPort)
.WithImage(builder.Required("Database:Image"))
.WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume()
.AddDatabase("vault-db", isSelfHosted ? "self_host_dev" : "vault_dev");
}
public static IResourceBuilder<AzureStorageResource> ConfigureAzurite(this IDistributedApplicationBuilder builder)
{
// For more information about this configuration: https://github.com/dotnet/aspire/discussions/5552
var azurite = builder
.AddAzureStorage("azurite").ConfigureInfrastructure(c =>
{
var blobStorage = c.GetProvisionableResources().OfType<BlobService>().SingleOrDefault();
blobStorage?.CorsRules.Add(new BicepValue<StorageCorsRule>(new StorageCorsRule
{
AllowedOrigins = [new BicepValue<string>("*")],
AllowedMethods = [CorsRuleAllowedMethod.Get, CorsRuleAllowedMethod.Put],
AllowedHeaders = [new BicepValue<string>("*")],
ExposedHeaders = [new BicepValue<string>("*")],
MaxAgeInSeconds = new BicepValue<int>("30")
}));
})
.RunAsEmulator(emulator =>
{
emulator
.WithBlobPort(10000)
.WithQueuePort(10001)
.WithTablePort(10002)
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent);
});
builder
.AddExecutable("azurite-setup", "pwsh", builder.Required("WorkingDirectory"), "-File",
builder.Required("Scripts:AzuriteSetup"))
.WaitFor(azurite)
.ExcludeFromManifest();
return azurite;
}
public static IResourceBuilder<ContainerResource> ConfigureMailCatcher(this IDistributedApplicationBuilder builder)
{
var (imageName, imageTag) = builder.GetImageParts("MailCatcher:Image");
if (!int.TryParse(builder.Required("MailCatcher:SmtpPort"), out var smtpPort))
throw new InvalidOperationException("Invalid value for MailCatcher:SmtpPort.");
if (!int.TryParse(builder.Required("MailCatcher:WebPort"), out var webPort))
throw new InvalidOperationException("Invalid value for MailCatcher:WebPort.");
return builder
.AddContainer("mailcatcher", imageName, imageTag)
.WithLifetime(ContainerLifetime.Persistent)
.WithEndpoint(port: smtpPort, name: "smtp", targetPort: 1025)
.WithHttpEndpoint(port: webPort, name: "web", targetPort: webPort);
}
public static IResourceBuilder<ContainerResource> ConfigureRedis(this IDistributedApplicationBuilder builder)
{
var (imageName, imageTag) = builder.GetImageParts("Redis:Image");
if (!int.TryParse(builder.Required("Redis:Port"), out var port))
throw new InvalidOperationException("Invalid value for Redis:Port.");
return builder
.AddContainer("redis", imageName, imageTag)
.WithLifetime(ContainerLifetime.Persistent)
.WithEndpoint(port: port, name: "tcp", targetPort: 6379)
.WithVolume("redis_data", "/data")
.WithArgs("redis-server", "--appendonly", "yes");
}
public static IResourceBuilder<ContainerResource> ConfigureIdp(this IDistributedApplicationBuilder builder)
{
var (imageName, imageTag) = builder.GetImageParts("Idp:Image");
if (!int.TryParse(builder.Required("Idp:Port"), out var port))
throw new InvalidOperationException("Invalid value for Idp:Port.");
return builder
.AddContainer("idp", imageName, imageTag)
.WithHttpEndpoint(port: port, name: "http", targetPort: 8080)
.WithEnvironment("SIMPLESAMLPHP_SP_ENTITY_ID", builder.Required("Idp:SpEntityId"))
.WithEnvironment("SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE", builder.Required("Idp:SpAcsUrl"))
.WithBindMount($"{builder.Required("WorkingDirectory")}/authsources.php",
"/var/www/simplesamlphp/config/authsources.php")
.WithExplicitStart();
}
/// <summary>
/// Configures and initializes the Bitwarden project services required for the distributed
/// application, keyed by service name.
/// </summary>
/// <param name="builder">The distributed application builder used to configure resources and services.</param>
/// <param name="db">The SQL Server database resource builder.</param>
/// <param name="secretsSetup">The executable resource builder for configuring secrets.</param>
/// <param name="mail">The container resource builder for setting up the mail service.</param>
/// <param name="azurite">The Azure Storage resource builder used to configure Azurite storage services.</param>
/// <returns>A dictionary of configured project resource builders keyed by service name.</returns>
public static IReadOnlyDictionary<string, IResourceBuilder<ProjectResource>> ConfigureServices(
this IDistributedApplicationBuilder builder,
IResourceBuilder<SqlServerDatabaseResource> db,
IResourceBuilder<ExecutableResource> secretsSetup,
IResourceBuilder<ContainerResource> mail,
IResourceBuilder<AzureStorageResource> azurite)
{
var services = new Dictionary<string, IResourceBuilder<ProjectResource>>
{
["admin"] = builder.AddBitwardenService<Projects.Admin>(db, secretsSetup, mail, "admin"),
["api"] = builder.AddBitwardenService<Projects.Api>(db, secretsSetup, mail, "api")
.WaitFor(azurite),
["billing"] = builder.AddBitwardenService<Projects.Billing>(db, secretsSetup, mail, "billing"),
["events"] = builder.AddBitwardenService<Projects.Events>(db, secretsSetup, mail, "events")
.WaitFor(azurite),
["eventsProcessor"] = builder
.AddBitwardenService<Projects.EventsProcessor>(db, secretsSetup, mail, "eventsProcessor")
.WaitFor(azurite),
["icons"] = builder.AddBitwardenService<Projects.Icons>(db, secretsSetup, mail, "icons"),
["identity"] = builder.AddBitwardenService<Projects.Identity>(db, secretsSetup, mail, "identity"),
["notifications"] = builder
.AddBitwardenService<Projects.Notifications>(db, secretsSetup, mail, "notifications")
.WaitFor(azurite),
["scim"] = builder.AddBitwardenService<Projects.Scim>(db, secretsSetup, mail, "scim"),
["sso"] = builder.AddBitwardenService<Projects.Sso>(db, secretsSetup, mail, "sso")
};
builder.ConfigureAdditionalProjects(services);
return services;
}
/// <summary>
/// Configures additional projects specified in the configuration under "AdditionalProjects".
/// This allows for dynamic inclusion of projects without code changes, useful for testing or temporary additions.
/// </summary>
/// <param name="builder">The distributed application builder used to access configuration and add project resources.</param>
/// <param name="services">All registered services keyed by name; each additional project's ReferencedBy list selects which ones receive a reference.</param>
private static void ConfigureAdditionalProjects(this IDistributedApplicationBuilder builder,
IReadOnlyDictionary<string, IResourceBuilder<ProjectResource>> services)
{
// Add via user-secrets: dotnet user-secrets set "AdditionalProjects:<name>:Path" "<path/to/Project.csproj>"
foreach (var section in builder.Configuration.GetSection("AdditionalProjects").GetChildren())
{
var path = section["Path"];
if (string.IsNullOrWhiteSpace(path))
continue;
var project = builder.AddProject(section.Key, path);
var referencedBy = section.GetSection("ReferencedBy").GetChildren().Select(c => c.Value).ToHashSet();
foreach (var (_, service) in services.Where(s => referencedBy.Contains(s.Key)))
service.WithReference(project);
}
}
/// <summary>
/// Adds and configures a Bitwarden service of the specified project type. This includes linking the service to
/// necessary resources such as a database, secrets setup, and optionally a mail service based on the project's name.
/// </summary>
/// <param name="builder">The distributed application builder used to configure the service.</param>
/// <param name="db">The SQL Server database resource to link to the service.</param>
/// <param name="secretsSetup">The executable resource responsible for secrets setup.</param>
/// <param name="mail">The container resource representing the mail service, used conditionally for specific projects.</param>
/// <param name="name">The unique name of the Bitwarden service being added.</param>
/// <typeparam name="TProject">The type of project implementing the <see cref="IProjectMetadata"/> interface.</typeparam>
/// <returns>The configured resource builder for the Bitwarden project resource.</returns>
private static IResourceBuilder<ProjectResource> AddBitwardenService<TProject>(
this IDistributedApplicationBuilder builder, IResourceBuilder<SqlServerDatabaseResource> db,
IResourceBuilder<ExecutableResource> secretsSetup, IResourceBuilder<ContainerResource> mail, string name)
where TProject : IProjectMetadata, new()
{
// launchSettings provide the ports for the services
var service = builder.AddProject<TProject>(name)
.WithEndpoint("http", e => e.Port = builder.GetBitwardenServicePort(name))
.WithReference(db)
.WaitFor(db)
.WaitForCompletion(secretsSetup);
if (name is "admin" or "identity" or "billing" or "sso")
service.WithReference(mail.GetEndpoint("smtp"));
return service;
}
private static int GetBitwardenServicePort(this IDistributedApplicationBuilder builder, string serviceName)
{
if (!int.TryParse(builder.Required($"Services:{serviceName}:BasePort"), out var basePort))
throw new InvalidOperationException($"Invalid port value for Services:{serviceName}:BasePort.");
return builder.IsSelfHosted() ? basePort + 1 : basePort;
}
/// <summary>
/// Retrieves a required configuration value and throws an exception if it's missing.
/// </summary>
/// <param name="builder"> An instance of <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="key"> The configuration key to retrieve.</param>
/// <returns> The configuration value associated with the specified key.</returns>
/// <exception cref="InvalidOperationException"></exception>
private static string Required(this IDistributedApplicationBuilder builder, string key) =>
builder.Configuration[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}");
/// <summary>
/// Reads an image reference from configuration in <c>name[:tag]</c> form and returns its components,
/// defaulting the tag to <c>latest</c> when not specified.
/// </summary>
private static (string Name, string Tag) GetImageParts(this IDistributedApplicationBuilder builder, string key)
{
var parts = builder.Required(key).Split(':');
return (parts[0], parts.Length > 1 ? parts[1] : "latest");
}
/// <summary>
/// Determines if the application is running in self-hosted mode.
/// </summary>
/// <param name="builder"> An instance of <see cref="IDistributedApplicationBuilder"/>.</param>
/// <returns> True if the application is self-hosted, otherwise false.</returns>
private static bool IsSelfHosted(this IDistributedApplicationBuilder builder) =>
builder.Configuration["SelfHost"]?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
public static void ConfigureWebFrontend(this IDistributedApplicationBuilder builder,
IResourceBuilder<ProjectResource> api)
{
if (!int.TryParse(builder.Required("WebFrontend:Port"), out var port))
throw new InvalidOperationException("Invalid value for WebFrontend:Port.");
builder
.AddBitwardenNpmApp("web-frontend", "web", api, port: port)
.WithUrl(builder.Required("WebFrontend:Url"))
.WithExternalHttpEndpoints();
}
private static IResourceBuilder<JavaScriptAppResource> AddBitwardenNpmApp(this IDistributedApplicationBuilder builder,
string name, string path, IResourceBuilder<ProjectResource> api, int port, string scriptName = "build:bit:watch")
{
return builder
.AddJavaScriptApp(name, $"{builder.Required("ClientsPath")}/{path}", scriptName)
.WithHttpsEndpoint(port, port, "angular-http", isProxied: false)
.WithNpm(install: false)
.WithReference(api)
.WaitFor(api)
.WithExplicitStart();
}
#if ENABLE_NGROK_COMMUNITY_PLUGIN
public static void ConfigureNgrok(this IDistributedApplicationBuilder builder,
(IResourceBuilder<ProjectResource>, string) tunnelResource)
{
var rawToken = builder.Configuration["NgrokAuthToken"];
if (string.IsNullOrWhiteSpace(rawToken))
return;
var authToken = builder.AddParameter("ngrok-auth-token", rawToken, secret: true);
builder.AddNgrok("billing-webhook-ngrok-endpoint", endpointPort: 59600)
.WithAuthToken(authToken)
.WithTunnelEndpoint(tunnelResource.Item1, tunnelResource.Item2)
.WithExplicitStart();
}
#endif
}