using Aspire.Hosting.Azure;
using Aspire.Hosting.JavaScript;
using Azure.Provisioning;
using Azure.Provisioning.Storage;
namespace Bit.AppHost;
public static class BuilderExtensions
{
///
/// Configures the secrets setup executable resource.
///
/// The distributed application builder used to configure the secrets setup resource.
/// >The configured resource builder for the secrets setup executable.
public static IResourceBuilder ConfigureSecrets(this IDistributedApplicationBuilder builder)
{
return builder
.AddExecutable("setup-secrets", "pwsh", "../dev", "-File", builder.Required("Scripts:SecretsSetup"),
"-clear")
.ExcludeFromManifest();
}
///
/// Configures the migrations executable resource.
///
/// The distributed application builder used to configure the migrations resource.
/// >The configured resource builder for the migrations executable.
public static IResourceBuilder ConfigureMigrations(this IDistributedApplicationBuilder builder)
{
var migrationArgs = new List { "-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 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 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().SingleOrDefault();
blobStorage?.CorsRules.Add(new BicepValue(new StorageCorsRule
{
AllowedOrigins = [new BicepValue("*")],
AllowedMethods = [CorsRuleAllowedMethod.Get, CorsRuleAllowedMethod.Put],
AllowedHeaders = [new BicepValue("*")],
ExposedHeaders = [new BicepValue("*")],
MaxAgeInSeconds = new BicepValue("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 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 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 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();
}
///
/// Configures and initializes the Bitwarden project services required for the distributed
/// application, keyed by service name.
///
/// The distributed application builder used to configure resources and services.
/// The SQL Server database resource builder.
/// The executable resource builder for configuring secrets.
/// The container resource builder for setting up the mail service.
/// The Azure Storage resource builder used to configure Azurite storage services.
/// A dictionary of configured project resource builders keyed by service name.
public static IReadOnlyDictionary> ConfigureServices(
this IDistributedApplicationBuilder builder,
IResourceBuilder db,
IResourceBuilder secretsSetup,
IResourceBuilder mail,
IResourceBuilder azurite)
{
var services = new Dictionary>
{
["admin"] = builder.AddBitwardenService(db, secretsSetup, mail, "admin"),
["api"] = builder.AddBitwardenService(db, secretsSetup, mail, "api")
.WaitFor(azurite),
["billing"] = builder.AddBitwardenService(db, secretsSetup, mail, "billing"),
["events"] = builder.AddBitwardenService(db, secretsSetup, mail, "events")
.WaitFor(azurite),
["eventsProcessor"] = builder
.AddBitwardenService(db, secretsSetup, mail, "eventsProcessor")
.WaitFor(azurite),
["icons"] = builder.AddBitwardenService(db, secretsSetup, mail, "icons"),
["identity"] = builder.AddBitwardenService(db, secretsSetup, mail, "identity"),
["notifications"] = builder
.AddBitwardenService(db, secretsSetup, mail, "notifications")
.WaitFor(azurite),
["scim"] = builder.AddBitwardenService(db, secretsSetup, mail, "scim"),
["sso"] = builder.AddBitwardenService(db, secretsSetup, mail, "sso")
};
builder.ConfigureAdditionalProjects(services);
return services;
}
///
/// 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.
///
/// The distributed application builder used to access configuration and add project resources.
/// All registered services keyed by name; each additional project's ReferencedBy list selects which ones receive a reference.
private static void ConfigureAdditionalProjects(this IDistributedApplicationBuilder builder,
IReadOnlyDictionary> services)
{
// Add via user-secrets: dotnet user-secrets set "AdditionalProjects::Path" ""
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);
}
}
///
/// 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.
///
/// The distributed application builder used to configure the service.
/// The SQL Server database resource to link to the service.
/// The executable resource responsible for secrets setup.
/// The container resource representing the mail service, used conditionally for specific projects.
/// The unique name of the Bitwarden service being added.
/// The type of project implementing the interface.
/// The configured resource builder for the Bitwarden project resource.
private static IResourceBuilder AddBitwardenService(
this IDistributedApplicationBuilder builder, IResourceBuilder db,
IResourceBuilder secretsSetup, IResourceBuilder mail, string name)
where TProject : IProjectMetadata, new()
{
// launchSettings provide the ports for the services
var service = builder.AddProject(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;
}
///
/// Retrieves a required configuration value and throws an exception if it's missing.
///
/// An instance of .
/// The configuration key to retrieve.
/// The configuration value associated with the specified key.
///
private static string Required(this IDistributedApplicationBuilder builder, string key) =>
builder.Configuration[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}");
///
/// Reads an image reference from configuration in name[:tag] form and returns its components,
/// defaulting the tag to latest when not specified.
///
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");
}
///
/// Determines if the application is running in self-hosted mode.
///
/// An instance of .
/// True if the application is self-hosted, otherwise false.
private static bool IsSelfHosted(this IDistributedApplicationBuilder builder) =>
builder.Configuration["SelfHost"]?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
public static void ConfigureWebFrontend(this IDistributedApplicationBuilder builder,
IResourceBuilder 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 AddBitwardenNpmApp(this IDistributedApplicationBuilder builder,
string name, string path, IResourceBuilder 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, 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
}