1
0
mirror of https://github.com/bitwarden/server synced 2026-02-20 03:13:35 +00:00

Refactoring structure of the CLI to be more maintainable long-term (#7042)

* Refactoring structure of the CLI to be more maintainable long-term
* Remove obvious comments & put back XML comments
This commit is contained in:
Mick Letofsky
2026-02-19 18:40:48 +01:00
committed by GitHub
parent 31fe7b0e12
commit 507c3a105c
15 changed files with 257 additions and 225 deletions

View File

@@ -131,7 +131,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seeder.csproj", "{9A612EBA-1C0E-42B8-982B-62F0EE81000A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederUtility", "util\SeederUtility\SeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
EndProject

View File

@@ -28,7 +28,7 @@ $projects = @{
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
SeederUtility = "../util/DbSeederUtility"
SeederUtility = "../util/SeederUtility"
}
foreach ($key in $projects.keys) {

View File

@@ -1,189 +0,0 @@
using System.Diagnostics;
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.DbSeederUtility;
public class Program
{
private static int Main(string[] args)
{
return new AppRunner<Program>()
.Run(args);
}
[Command("organization", Description = "Seed an organization and organization users")]
public void Organization(
[Option('n', "Name", Description = "Name of organization")]
string name,
[Option('u', "users", Description = "Number of users to generate")]
int users,
[Option('d', "domain", Description = "Email domain for users")]
string domain
)
{
// Create service provider with necessary services
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
// Get a scoped DB context
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
var mapper = scopedServices.GetRequiredService<IMapper>();
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
recipe.Seed(name: name, domain: domain, users: users);
}
[Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")]
public void VaultOrganization(VaultOrganizationArgs args)
{
args.Validate();
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithVaultRecipe(
scopedServices.GetRequiredService<DatabaseContext>(),
scopedServices.GetRequiredService<IMapper>(),
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
manglerService);
recipe.Seed(args.ToOptions());
if (!manglerService.IsEnabled)
{
return;
}
var map = manglerService.GetMangleMap();
Console.WriteLine("--- Mangled Data Map ---");
foreach (var (original, mangled) in map)
{
Console.WriteLine($"{original} -> {mangled}");
}
}
[Command("seed", Description = "Seed database using fixture-based presets")]
public void Seed(SeedArgs args)
{
try
{
args.Validate();
// Handle list mode - no database needed
if (args.List)
{
var available = OrganizationFromPresetRecipe.ListAvailable();
PrintAvailableSeeds(available);
return;
}
// Create service provider - same pattern as other commands
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
var mapper = scopedServices.GetRequiredService<IMapper>();
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
var manglerService = scopedServices.GetRequiredService<IManglerService>();
// Create recipe - CLI is "dumb", recipe handles complexity
var recipe = new OrganizationFromPresetRecipe(db, mapper, passwordHasher, manglerService);
var stopwatch = Stopwatch.StartNew();
Console.WriteLine($"Seeding organization from preset '{args.Preset}'...");
var result = recipe.Seed(args.Preset!, args.Password);
stopwatch.Stop();
PrintSeedResult(result, stopwatch.Elapsed);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Environment.Exit(1);
}
}
private static void PrintAvailableSeeds(AvailableSeeds available)
{
Console.WriteLine("Available Presets:");
foreach (var preset in available.Presets)
{
Console.WriteLine($" - {preset}");
}
Console.WriteLine();
Console.WriteLine("Available Fixtures:");
foreach (var (category, fixtures) in available.Fixtures.OrderBy(kvp => kvp.Key))
{
// Guard: Skip empty or single-character categories to prevent IndexOutOfRangeException
if (string.IsNullOrEmpty(category) || category.Length < 2)
{
continue;
}
var categoryName = char.ToUpperInvariant(category[0]) + category[1..];
Console.WriteLine($" {categoryName}:");
foreach (var fixture in fixtures)
{
Console.WriteLine($" - {fixture}");
}
}
Console.WriteLine();
Console.WriteLine("Use: DbSeeder.exe seed --preset <name>");
}
private static void PrintSeedResult(SeedResult result, TimeSpan elapsed)
{
Console.WriteLine($"✓ Created organization (ID: {result.OrganizationId})");
if (result.OwnerEmail is not null)
{
Console.WriteLine($"✓ Owner: {result.OwnerEmail}");
}
if (result.UsersCount > 0)
{
Console.WriteLine($"✓ Created {result.UsersCount} users");
}
if (result.GroupsCount > 0)
{
Console.WriteLine($"✓ Created {result.GroupsCount} groups");
}
if (result.CollectionsCount > 0)
{
Console.WriteLine($"✓ Created {result.CollectionsCount} collections");
}
if (result.CiphersCount > 0)
{
Console.WriteLine($"✓ Created {result.CiphersCount} ciphers");
}
Console.WriteLine($"Done in {elapsed.TotalSeconds:F1}s");
}
}

View File

@@ -4,7 +4,7 @@
**For detailed pattern descriptions (Factories, Recipes, Models, Scenes, Queries, Data), read `README.md`.**
**For detailed usages of the Seeder library, read `util/DbSeederUtility/README.md` and `util/SeederApi/README.md`**
**For detailed usages of the Seeder library, read `util/SeederUtility/README.md` and `util/SeederApi/README.md`**
## Commands

View File

@@ -200,7 +200,7 @@ The Seeder is organized around six core patterns, each with a specific responsib
**Configuration:**
- SeederApi: Enabled when `GlobalSettings.TestPlayIdTrackingEnabled` is true
- DbSeederUtility: Enabled with `--mangle` CLI flag
- SeederUtility: Enabled with `--mangle` CLI flag
---

View File

@@ -0,0 +1,40 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Bit.SeederUtility.Configuration;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.SeederUtility.Commands;
[Command("organization", Description = "Seed an organization and organization users")]
public class OrganizationCommand
{
[DefaultCommand]
public void Execute(
[Option('n', "Name", Description = "Name of organization")]
string name,
[Option('u', "users", Description = "Number of users to generate")]
int users,
[Option('d', "domain", Description = "Email domain for users")]
string domain
)
{
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
var mapper = scopedServices.GetRequiredService<IMapper>();
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithUsersRecipe(db, mapper, passwordHasher, manglerService);
recipe.Seed(name: name, domain: domain, users: users);
}
}

View File

@@ -1,6 +1,6 @@
using CommandDotNet;
namespace Bit.DbSeederUtility;
namespace Bit.SeederUtility.Commands;
/// <summary>
/// CLI argument model for the seed command.
@@ -22,17 +22,14 @@ public class SeedArgs : IArgumentModel
public void Validate()
{
// List mode is standalone
if (List)
{
return;
}
// Must specify preset
if (string.IsNullOrEmpty(Preset))
{
throw new ArgumentException(
"--preset must be specified. Use --list to see available presets.");
throw new ArgumentException("--preset must be specified. Use --list to see available presets.");
}
}
}

View File

@@ -0,0 +1,115 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Bit.SeederUtility.Configuration;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.SeederUtility.Commands;
[Command("seed", Description = "Seed database using fixture-based presets")]
public class SeedCommand
{
[DefaultCommand]
public void Execute(SeedArgs args)
{
try
{
args.Validate();
if (args.List)
{
var available = OrganizationFromPresetRecipe.ListAvailable();
PrintAvailableSeeds(available);
return;
}
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<DatabaseContext>();
var mapper = scopedServices.GetRequiredService<IMapper>();
var passwordHasher = scopedServices.GetRequiredService<IPasswordHasher<User>>();
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationFromPresetRecipe(db, mapper, passwordHasher, manglerService);
Console.WriteLine($"Seeding organization from preset '{args.Preset}'...");
var result = recipe.Seed(args.Preset!, args.Password);
PrintSeedResult(result);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Environment.Exit(1);
}
}
private static void PrintAvailableSeeds(AvailableSeeds available)
{
Console.WriteLine("Available Presets:");
foreach (var preset in available.Presets)
{
Console.WriteLine($" - {preset}");
}
Console.WriteLine();
Console.WriteLine("Available Fixtures:");
foreach (var (category, fixtures) in available.Fixtures.OrderBy(kvp => kvp.Key))
{
// Guard: Skip empty or single-character categories to prevent IndexOutOfRangeException
if (string.IsNullOrEmpty(category) || category.Length < 2)
{
continue;
}
var categoryName = char.ToUpperInvariant(category[0]) + category[1..];
Console.WriteLine($" {categoryName}:");
foreach (var fixture in fixtures)
{
Console.WriteLine($" - {fixture}");
}
}
Console.WriteLine();
Console.WriteLine("Use: SeederUtility seed --preset <name>");
}
private static void PrintSeedResult(SeedResult result)
{
Console.WriteLine($"✓ Created organization (ID: {result.OrganizationId})");
if (result.OwnerEmail is not null)
{
Console.WriteLine($"✓ Owner: {result.OwnerEmail}");
}
if (result.UsersCount > 0)
{
Console.WriteLine($"✓ Created {result.UsersCount} users");
}
if (result.GroupsCount > 0)
{
Console.WriteLine($"✓ Created {result.GroupsCount} groups");
}
if (result.CollectionsCount > 0)
{
Console.WriteLine($"✓ Created {result.CollectionsCount} collections");
}
if (result.CiphersCount > 0)
{
Console.WriteLine($"✓ Created {result.CiphersCount} ciphers");
}
}
}

View File

@@ -3,7 +3,7 @@ using Bit.Seeder.Factories;
using Bit.Seeder.Options;
using CommandDotNet;
namespace Bit.DbSeederUtility;
namespace Bit.SeederUtility.Commands;
/// <summary>
/// CLI argument model for the vault-organization command.

View File

@@ -0,0 +1,49 @@
using AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Recipes;
using Bit.Seeder.Services;
using Bit.SeederUtility.Configuration;
using CommandDotNet;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.SeederUtility.Commands;
[Command("vault-organization", Description = "Seed an organization with users and encrypted vault data (ciphers, collections, groups)")]
public class VaultOrganizationCommand
{
[DefaultCommand]
public void Execute(VaultOrganizationArgs args)
{
args.Validate();
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services, enableMangling: args.Mangle);
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var manglerService = scopedServices.GetRequiredService<IManglerService>();
var recipe = new OrganizationWithVaultRecipe(
scopedServices.GetRequiredService<DatabaseContext>(),
scopedServices.GetRequiredService<IMapper>(),
scopedServices.GetRequiredService<IPasswordHasher<User>>(),
manglerService);
recipe.Seed(args.ToOptions());
if (!manglerService.IsEnabled)
{
return;
}
var map = manglerService.GetMangleMap();
Console.WriteLine("--- Mangled Data Map ---");
foreach (var (original, mangled) in map)
{
Console.WriteLine($"{original} -> {mangled}");
}
}
}

View File

@@ -1,7 +1,7 @@
using Bit.Core.Settings;
using Microsoft.Extensions.Configuration;
namespace Bit.DbSeederUtility;
namespace Bit.SeederUtility.Configuration;
public static class GlobalSettingsFactory
{
@@ -20,7 +20,7 @@ public static class GlobalSettingsFactory
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true)
.AddUserSecrets("bitwarden-Api") // Load user secrets from the API project
.AddUserSecrets("bitwarden-Api")
.AddEnvironmentVariables();
var configuration = configBuilder.Build();

View File

@@ -7,16 +7,14 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Bit.DbSeederUtility;
namespace Bit.SeederUtility.Configuration;
public static class ServiceCollectionExtension
{
public static void ConfigureServices(ServiceCollection services, bool enableMangling = false)
{
// Load configuration using the GlobalSettingsFactory
var globalSettings = GlobalSettingsFactory.GlobalSettings;
// Register services
services.AddLogging(builder =>
{
builder.AddConsole();
@@ -27,9 +25,7 @@ public static class ServiceCollectionExtension
services.AddSingleton<IPasswordHasher<User>, PasswordHasher<User>>();
services.TryAddSingleton<ISeedReader, SeedReader>();
// Add Data Protection services
services.AddDataProtection()
.SetApplicationName("Bitwarden");
services.AddDataProtection().SetApplicationName("Bitwarden");
services.AddDatabaseRepositories(globalSettings);

View File

@@ -0,0 +1,22 @@
using Bit.SeederUtility.Commands;
using CommandDotNet;
namespace Bit.SeederUtility;
public class Program
{
private static int Main(string[] args)
{
return new AppRunner<Program>()
.Run(args);
}
[Subcommand]
public OrganizationCommand Organization { get; set; } = null!;
[Subcommand]
public VaultOrganizationCommand VaultOrganization { get; set; } = null!;
[Subcommand]
public SeedCommand Seed { get; set; } = null!;
}

View File

@@ -1,10 +1,10 @@
# Bitwarden Database Seeder Utility
# Bitwarden Seeder Utility
A CLI wrapper around the Seeder library for generating test data in your local Bitwarden database.
A CLI wrapper around the Seeder library for generating test data in a Bitwarden database.
## Getting Started
Build and run from the `util/DbSeederUtility` directory:
Build and run from the `util/SeederUtility` directory:
```bash
dotnet build
@@ -15,6 +15,16 @@ dotnet run -- <command> [options]
## Commands
### `organization` - Users Only (No Vault Data)
```bash
# 100 users
dotnet run -- organization -n MyOrgNoCiphers -u 100 -d myorg-no-ciphers.com
# 10,000 users for load testing
dotnet run -- organization -n LargeOrgNoCiphers -u 10000 -d large-org-no-ciphers.test
```
### `seed` - Fixture-Based Seeding
```bash
@@ -22,25 +32,17 @@ dotnet run -- <command> [options]
dotnet run -- seed --list
# Load the Dunder Mifflin preset (58 users, 14 groups, 15 collections, ciphers)
dotnet run -- seed --preset dunder-mifflin-full
dotnet run -- seed --preset dunder-mifflin-enterprise-full
# Load with ID mangling for test isolation
dotnet run -- seed --preset dunder-mifflin-full --mangle
dotnet run -- seed --preset dunder-mifflin-enterprise-full --mangle
dotnet run -- seed --preset stark-free-basic --mangle
# Large enterprise preset for performance testing
dotnet run -- seed --preset large-enterprise
dotnet run -- seed --preset dunder-mifflin-full --password "MyTestPassword1" --mangle
```
### `organization` - Users Only (No Vault Data)
```bash
# 100 users
dotnet run -- organization -n MyOrg -u 100 -d myorg.com
# 10,000 users for load testing
dotnet run -- organization -n seeded -u 10000 -d large.test
dotnet run -- seed --preset dunder-mifflin-enterprise-full --password "MyTestPassword1" --mangle
```
### `vault-organization` - Users + Encrypted Vault Data

View File

@@ -5,8 +5,8 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Bit.DbSeederUtility</RootNamespace>
<AssemblyName>DbSeeder</AssemblyName>
<RootNamespace>Bit.SeederUtility</RootNamespace>
<AssemblyName>SeederUtility</AssemblyName>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<UserSecretsId>2294c6ba-7cd0-4293-a797-3882e41c61cb</UserSecretsId>
</PropertyGroup>