Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
f570887
feat: Add Aspire Apphost and ServiceDefault projects
sbrown-livefront Dec 10, 2025
7597b4e
feat: Add compiler conditional ServiceDefaults to included projects
sbrown-livefront Dec 10, 2025
f8a7a66
Merge branch 'main' into billing/aspire
sbrown-livefront Dec 31, 2025
b8f4186
Merge branch 'main' into billing/aspire
sbrown-livefront Jan 12, 2026
314909f
Merge branch 'main' into billing/aspire
sbrown-livefront Jan 15, 2026
b064160
Merge branch 'main' into billing/aspire
sbrown-livefront Jan 19, 2026
3e1ee8a
fix(billing): update otlexporter duplicate call
sbrown-livefront Jan 19, 2026
e12f2e9
Merge branch 'main' into billing/aspire
sbrown-livefront Jan 20, 2026
fe72d2c
fix(billing): update imports
sbrown-livefront Jan 20, 2026
a069e72
fix(billing): revert unintended changes
sbrown-livefront Jan 20, 2026
79fd6e0
fix(billing): revert unintended changes
sbrown-livefront Jan 20, 2026
f7a8070
fix(billing): fix unintended files
sbrown-livefront Jan 20, 2026
471cb8d
fix(billing): add apphost and extension methods
sbrown-livefront Jan 20, 2026
f741425
fix(billing): update aspire
sbrown-livefront Jan 21, 2026
9f6286f
fix(billing): update references to match integration test configuratiโ€ฆ
sbrown-livefront Jan 21, 2026
35ca62a
chore(billing): update secrets.json example
sbrown-livefront Jan 21, 2026
25d409e
Merge branch 'main' into billing/aspire
sbrown-livefront Jan 21, 2026
62c57cf
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 3, 2026
ea2a44e
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 4, 2026
d9afdc6
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 4, 2026
966ff1e
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 5, 2026
c6decac
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 9, 2026
8ad7de3
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 10, 2026
e9e95bf
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 13, 2026
f953e5e
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 17, 2026
9b7af51
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 19, 2026
84240e4
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 20, 2026
47b4e04
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 23, 2026
871e629
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 24, 2026
415c14f
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 25, 2026
54c7249
Merge branch 'main' into billing/aspire
sbrown-livefront Feb 26, 2026
808e5e9
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 2, 2026
3e094ad
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 3, 2026
e626e2c
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 4, 2026
ef725c2
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 4, 2026
0fdd4c4
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 4, 2026
389bf60
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 5, 2026
9e5eadf
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 6, 2026
fca5b82
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 9, 2026
c9f0dc4
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 9, 2026
6755a3c
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 17, 2026
f95046d
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 17, 2026
c8f15de
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 18, 2026
8960ded
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 18, 2026
05b5142
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 19, 2026
0361e60
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 23, 2026
6aead6b
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 24, 2026
aa395b7
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 24, 2026
1cf8267
Merge branch 'main' into billing/aspire
sbrown-livefront Mar 25, 2026
1101122
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 1, 2026
f62666d
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 1, 2026
a16f601
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 2, 2026
fec274b
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 2, 2026
4e8a3bb
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 6, 2026
a2b006a
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 6, 2026
ee06de4
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 7, 2026
1c14b9f
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 10, 2026
a05a1cf
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 13, 2026
aa657a7
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 15, 2026
74b7ee1
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 23, 2026
4e45884
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 28, 2026
a742cea
Merge branch 'main' into billing/aspire
sbrown-livefront Apr 28, 2026
798fa3f
refactor(Aspire): remove ServiceDefaults project
sbrown-livefront Apr 28, 2026
8fdd6dc
build(AppHost): update Aspire SDK and add conditional community plugins
sbrown-livefront Apr 28, 2026
59de062
refactor(AppHost): centralize resource orchestration calls to Builderโ€ฆ
sbrown-livefront Apr 28, 2026
a899e3f
feat(AppHost): implement configuration-driven resource setup
sbrown-livefront Apr 28, 2026
4fa8a32
chore(dev): remove obsolete configuration from development files
sbrown-livefront Apr 28, 2026
3d87c72
fix(billing): run dotnet format
sbrown-livefront Apr 28, 2026
5334991
fix(billing): revert changes
sbrown-livefront Apr 28, 2026
ed09ed1
fix: solution spacing
sbrown-livefront Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .aspire/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"appHostPath": "../AppHost/AppHost.csproj"
}
22 changes: 22 additions & 0 deletions AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
๏ปฟusing Bit.AppHost;

var builder = DistributedApplication.CreateBuilder(args);
var secretsSetup = builder.ConfigureSecrets();
var db = builder.AddSqlServerDatabaseResource();
builder.ConfigureMigrations()
.WaitFor(db)
.ExcludeFromManifest()
.WaitForCompletion(secretsSetup);
var azurite = builder.ConfigureAzurite();
var mail = builder.ConfigureMailCatcher();
var (_, api, billing, _, _) = builder.ConfigureServices(db, secretsSetup, mail, azurite);

#if ENABLE_NODEJS_COMMUNITY_PLUGIN
builder.ConfigureWebFrontend(api);
#endif

#if ENABLE_NGROK_COMMUNITY_PLUGIN
builder.ConfigureNgrok((billing, "billing-http"));
#endif

builder.Build().Run();
41 changes: 41 additions & 0 deletions AppHost/AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>e0dba0c6-d131-43bd-9143-2260f11a14ad</UserSecretsId>
</PropertyGroup>

<PropertyGroup Label="Aspire AppHost Community Plugin settings">
<!-- Disable community plugins by default -->
<EnableNgrokCommunityPlugin>false</EnableNgrokCommunityPlugin>
<DefineConstants Condition="'$(EnableNgrokCommunityPlugin)' == 'true'">$(DefineConstants);ENABLE_NGROK_COMMUNITY_PLUGIN</DefineConstants>
<EnableNodeJsCommunityPlugin>false</EnableNodeJsCommunityPlugin>
<DefineConstants Condition="'$(EnableNodeJsCommunityPlugin)' == 'true'">$(DefineConstants);ENABLE_NODEJS_COMMUNITY_PLUGIN</DefineConstants>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.4"/>
<PackageReference Include="Aspire.Hosting.Azure.Storage" Version="13.2.4"/>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.2.4"/>
<PackageReference Include="Aspire.Hosting.SqlServer" Version="13.2.4"/>
</ItemGroup>

<ItemGroup Condition="'$(EnableNgrokCommunityPlugin)' == 'true'">
<PackageReference Include="CommunityToolkit.Aspire.Hosting.Ngrok" Version="13.1.1"/>
</ItemGroup>

<ItemGroup Condition="'$(EnableNodeJsCommunityPlugin)' == 'true'">
<PackageReference Include="CommunityToolkit.Aspire.Hosting.NodeJS.Extensions" Version="9.9.0"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\Admin\Admin.csproj"/>
<ProjectReference Include="..\src\Api\Api.csproj"/>
<ProjectReference Include="..\src\Billing\Billing.csproj"/>
<ProjectReference Include="..\src\Identity\Identity.csproj"/>
<ProjectReference Include="..\src\Notifications\Notifications.csproj"/>
</ItemGroup>
</Project>
261 changes: 261 additions & 0 deletions AppHost/BuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
๏ปฟusing Aspire.Hosting.Azure;
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>().Single();
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(c =>
{
c.WithBlobPort(10000)
.WithQueuePort(10001)
.WithTablePort(10002);
});

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 image = builder.Required("MailCatcher:Image");
var imageParts = image.Split(':');
var imageName = imageParts[0];
var imageTag = imageParts.Length > 1 ? imageParts[1] : "latest";

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);
}

/// <summary>
/// Configures and initializes the essential services required for the distributed application,
/// including project-specific services such as admin, API, billing, identity, and notifications.
/// </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 tuple containing resource builders for the admin, API, billing, identity, and notifications projects.</returns>
public static (
IResourceBuilder<ProjectResource> admin,
IResourceBuilder<ProjectResource> api,
IResourceBuilder<ProjectResource> billing,
IResourceBuilder<ProjectResource> identity,
IResourceBuilder<ProjectResource> notifications
) ConfigureServices(
this IDistributedApplicationBuilder builder,
IResourceBuilder<SqlServerDatabaseResource> db,
IResourceBuilder<ExecutableResource> secretsSetup,
IResourceBuilder<ContainerResource> mail,
IResourceBuilder<AzureStorageResource> azurite)
{
var admin = builder.AddBitwardenService<Projects.Admin>(db, secretsSetup, mail, "admin");
var api = builder.AddBitwardenService<Projects.Api>(db, secretsSetup, mail, "api")
.WaitFor(azurite);
var billing = builder.AddBitwardenService<Projects.Billing>(db, secretsSetup, mail, "billing");
var identity = builder.AddBitwardenService<Projects.Identity>(db, secretsSetup, mail, "identity");
var notifications = builder.AddBitwardenService<Projects.Notifications>(db, secretsSetup, mail, "notifications")
.WaitFor(azurite);
builder.ConfigureAdditionalProjects(new Dictionary<string, IResourceBuilder<ProjectResource>>
{
["admin"] = admin,
["api"] = api,
["billing"] = billing,
["identity"] = identity,
["notifications"] = notifications
});
return (admin, api, billing, identity, notifications);
}

/// <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")
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>
/// 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;

#if ENABLE_NODEJS_COMMUNITY_PLUGIN
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)
.WithHttpsEndpoint(port, port, "angular-http", isProxied: false)
.WithUrl(builder.Required("WebFrontend:Url"))
.WithExternalHttpEndpoints();
}

private static IResourceBuilder<NodeAppResource> AddBitwardenNpmApp(this IDistributedApplicationBuilder builder,
string name, string path, IResourceBuilder<ProjectResource> api, string scriptName = "build:bit:watch")
{
return builder
.AddNpmApp(name, $"{builder.Required("ClientsPath")}/{path}", scriptName)
.WithReference(api)
.WaitFor(api)
.WithExplicitStart();
}
#endif

#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
}
29 changes: 29 additions & 0 deletions AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17271;http://localhost:15055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21022",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22177"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19147",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20252"
}
}
}
}
Loading
Loading