Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion util/Seeder/Options/SeederDependencies.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ο»Ώusing AutoMapper;
using Bit.Core.Entities;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Pipeline;
using Bit.Seeder.Services;
using Microsoft.AspNetCore.Identity;

Expand All @@ -13,4 +14,11 @@ public sealed record SeederDependencies(
DatabaseContext Db,
IMapper Mapper,
IPasswordHasher<User> PasswordHasher,
IManglerService ManglerService);
IManglerService ManglerService)
{
/// <summary>
/// Optional progress reporter. When null, the pipeline runs silently.
/// Set via <c>with</c> expression from UI-facing callers (e.g., CLI).
/// </summary>
public IProgress<SeederProgressEvent>? Progress { get; init; }
}
48 changes: 48 additions & 0 deletions util/Seeder/Pipeline/ProgressTicker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
ο»Ώnamespace Bit.Seeder.Pipeline;

/// <summary>
/// Batches <see cref="PhaseAdvanced"/> events so a sequential loop over N items emits at most ~100 events.
/// Caller is responsible for calling <see cref="PhaseStarted"/>/<see cref="PhaseCompleted"/>; this
/// type only handles per-iteration flushing. Not thread-safe β€” parallel loops should use thread-local
/// counters and report directly via <see cref="IProgress{T}.Report"/>.
/// </summary>
internal sealed class ProgressTicker
{
private readonly IProgress<SeederProgressEvent>? _progress;
private readonly string _phase;
private readonly int _batchSize;
private int _pending;

internal ProgressTicker(IProgress<SeederProgressEvent>? progress, string phase, int total)
{
_progress = progress;
_phase = phase;
_batchSize = Math.Max(1, total / 100);
}

internal void Tick()
{
if (_progress is null)
{
return;
}

_pending++;
if (_pending >= _batchSize)
{
_progress.Report(new PhaseAdvanced(_phase, _pending));
_pending = 0;
}
}

internal void Flush()
{
if (_progress is null || _pending <= 0)
{
return;
}

_progress.Report(new PhaseAdvanced(_phase, _pending));
_pending = 0;
}
}
17 changes: 16 additions & 1 deletion util/Seeder/Pipeline/RecipeExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

namespace Bit.Seeder.Pipeline;

internal static class SeederPhases
Copy link
Copy Markdown
Contributor

@theMickster theMickster Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ SeederPhases is here solely to hold CommittingToDatabase, while CreateCiphersStep, CreateCollectionsStep, CreateGroupsStep, CreateUsersStep, and GeneratePersonalCiphersStep each define their own file-local private const string Phase.

Let's put them all into this simple class of constants, move it to it's own file, and use it for phase definitions since multiple patterns invites drift.

{
internal const string CommittingToDatabase = "Committing to database";
}

/// <summary>
/// Resolves steps from DI by recipe key and executes them in order.
/// </summary>
Expand Down Expand Up @@ -56,7 +61,17 @@ internal PipelineExecutionResult Execute()
context.Ciphers.Count,
context.Folders.Count);

_committer.Commit(context);
var progress = context.GetProgress();
progress?.Report(new PhaseStarted(SeederPhases.CommittingToDatabase, null));
try
{
_committer.Commit(context);
}
finally
{
progress?.Report(new PhaseCompleted(SeederPhases.CommittingToDatabase));
}

return result;
}
}
12 changes: 12 additions & 0 deletions util/Seeder/Pipeline/RecipeOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal PipelineExecutionResult Execute(
services.AddSingleton<ISeedReader>(reader);
services.AddSingleton(new SeederSettings(password, effectiveKdf));
services.AddSingleton(deps.Db);
if (deps.Progress is not null)
{
services.AddSingleton(deps.Progress);
}

PresetLoader.RegisterRecipe(presetName, reader, services);

Expand All @@ -50,6 +54,10 @@ internal PipelineExecutionResult Execute(OrganizationVaultOptions options)
services.AddSingleton(deps.PasswordHasher);
services.AddSingleton(deps.ManglerService);
services.AddSingleton(new SeederSettings(options.Password, options.KdfIterations));
if (deps.Progress is not null)
{
services.AddSingleton(deps.Progress);
}

var recipeName = "from-options";
var builder = services.AddRecipe(recipeName);
Expand Down Expand Up @@ -105,6 +113,10 @@ internal PipelineExecutionResult Execute(IndividualUserOptions options)
services.AddSingleton(deps.PasswordHasher);
services.AddSingleton(deps.ManglerService);
services.AddSingleton(new SeederSettings(options.Password, options.KdfIterations));
if (deps.Progress is not null)
{
services.AddSingleton(deps.Progress);
}

var recipeName = "individual-from-options";
var builder = services.AddRecipe(recipeName);
Expand Down
7 changes: 7 additions & 0 deletions util/Seeder/Pipeline/SeederContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ internal static string GetPassword(this SeederContext context) =>

internal static int GetKdfIterations(this SeederContext context) =>
context.GetSettings().KdfIterations;

/// <summary>
/// Resolves the optional progress reporter. Returns null when no UI is attached.
/// Callers should null-check before constructing events to keep the no-reporter path allocation-free.
/// </summary>
internal static IProgress<SeederProgressEvent>? GetProgress(this SeederContext context) =>
context.Services.GetService<IProgress<SeederProgressEvent>>();
}

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions util/Seeder/Pipeline/SeederProgressEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ο»Ώnamespace Bit.Seeder.Pipeline;

/// <summary>
/// Events emitted by the seeder pipeline for progress reporting.
/// Consumed via <see cref="System.IProgress{T}"/> to keep the library UI-agnostic.
/// </summary>
public abstract record SeederProgressEvent(string Phase);

/// <summary>Emitted when a phase begins. <paramref name="Total"/> is null for indeterminate work.</summary>
public sealed record PhaseStarted(string Phase, int? Total) : SeederProgressEvent(Phase);

/// <summary>Emitted periodically as a phase makes progress. <paramref name="Delta"/> is incremental, not cumulative.</summary>
public sealed record PhaseAdvanced(string Phase, int Delta) : SeederProgressEvent(Phase);

/// <summary>Emitted when a phase completes.</summary>
public sealed record PhaseCompleted(string Phase) : SeederProgressEvent(Phase);
11 changes: 11 additions & 0 deletions util/Seeder/Steps/CreateCiphersStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace Bit.Seeder.Steps;
/// </summary>
internal sealed class CreateCiphersStep : IStep
{
private const string Phase = "Creating ciphers";
private readonly string _fixtureName;
private readonly bool _skipCollectionAssignment;
private readonly bool _personal;
Expand Down Expand Up @@ -51,10 +52,14 @@ public void Execute(SeederContext context)

var seedFile = context.GetSeedReader().Read<SeedFile>($"ciphers.{_fixtureName}");
var collectionIds = context.Registry.CollectionIds;
var progress = context.GetProgress();

var ciphers = new List<Cipher>(seedFile.Items.Count);
var collectionCiphers = new List<CollectionCipher>();

progress?.Report(new PhaseStarted(Phase, seedFile.Items.Count));
var ticker = new ProgressTicker(progress, Phase, seedFile.Items.Count);

for (var i = 0; i < seedFile.Items.Count; i++)
{
var item = seedFile.Items[i];
Expand Down Expand Up @@ -93,6 +98,8 @@ public void Execute(SeederContext context)

context.Registry.FixtureCipherNameToId[item.Name] = cipher.Id;

ticker.Tick();

// Collection assignment (round-robin, skipped for personal vaults or when collectionAssignments handles it)
if (_skipCollectionAssignment || collectionIds.Count <= 0)
{
Expand All @@ -116,7 +123,11 @@ public void Execute(SeederContext context)
}
}

ticker.Flush();

context.Ciphers.AddRange(ciphers);
context.CollectionCiphers.AddRange(collectionCiphers);

progress?.Report(new PhaseCompleted(Phase));
}
}
31 changes: 25 additions & 6 deletions util/Seeder/Steps/CreateCollectionsStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Bit.Seeder.Steps;

internal sealed class CreateCollectionsStep : IStep
{
private const string Phase = "Creating collections";
private readonly int _count;
private readonly OrgStructureModel? _structure;
private readonly DensityProfile? _density;
Expand All @@ -30,21 +31,35 @@ public void Execute(SeederContext context)
var orgId = context.RequireOrgId();
var orgKey = context.RequireOrgKey();
var hardenedOrgUserIds = context.Registry.HardenedOrgUserIds;
var progress = context.GetProgress();

List<Collection> collections;

if (_structure.HasValue)
{
var orgStructure = OrgStructures.GetStructure(_structure.Value);
collections = orgStructure.Units
.Select(unit => CollectionSeeder.Create(orgId, orgKey, unit.Name))
.ToList();
var unitCount = orgStructure.Units.Length;
progress?.Report(new PhaseStarted(Phase, unitCount));
var structTicker = new ProgressTicker(progress, Phase, unitCount);
collections = new List<Collection>(unitCount);
foreach (var unit in orgStructure.Units)
{
collections.Add(CollectionSeeder.Create(orgId, orgKey, unit.Name));
structTicker.Tick();
}
structTicker.Flush();
}
else
{
collections = Enumerable.Range(0, _count)
.Select(i => CollectionSeeder.Create(orgId, orgKey, $"Collection {i + 1}"))
.ToList();
progress?.Report(new PhaseStarted(Phase, _count));
var countTicker = new ProgressTicker(progress, Phase, _count);
collections = new List<Collection>(_count);
for (var i = 0; i < _count; i++)
{
collections.Add(CollectionSeeder.Create(orgId, orgKey, $"Collection {i + 1}"));
countTicker.Tick();
}
countTicker.Flush();
}

var collectionIds = collections.Select(c => c.Id).ToList();
Expand All @@ -54,6 +69,7 @@ public void Execute(SeederContext context)

if (collections.Count == 0)
{
progress?.Report(new PhaseCompleted(Phase));
return;
}

Expand All @@ -76,6 +92,7 @@ public void Execute(SeederContext context)
}
}
context.CollectionUsers.AddRange(collectionUsers);
progress?.Report(new PhaseCompleted(Phase));
return;
}

Expand All @@ -95,6 +112,8 @@ public void Execute(SeederContext context)
ApplyUserPermissions(directUsers, _density.PermissionDistribution);
context.CollectionUsers.AddRange(directUsers);
}

progress?.Report(new PhaseCompleted(Phase));
}

internal List<CollectionGroup> BuildCollectionGroups(List<Guid> collectionIds, List<Guid> groupIds)
Expand Down
9 changes: 9 additions & 0 deletions util/Seeder/Steps/CreateGroupsStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,30 @@ namespace Bit.Seeder.Steps;

internal sealed class CreateGroupsStep(int count, DensityProfile? density = null) : IStep
{
private const string Phase = "Creating groups";
private readonly DensityProfile? _density = density;

public void Execute(SeederContext context)
{
var orgId = context.RequireOrgId();
var hardenedOrgUserIds = context.Registry.HardenedOrgUserIds;
var progress = context.GetProgress();

var groups = new List<Group>(count);
var groupIds = new List<Guid>(count);
var groupUsers = new List<GroupUser>(hardenedOrgUserIds.Count);

progress?.Report(new PhaseStarted(Phase, count));
var ticker = new ProgressTicker(progress, Phase, count);

for (var i = 0; i < count; i++)
{
var group = GroupSeeder.Create(orgId, $"Group {i + 1}");
groups.Add(group);
groupIds.Add(group.Id);
ticker.Tick();
}
ticker.Flush();

context.Groups.AddRange(groups);

Expand Down Expand Up @@ -62,6 +69,8 @@ public void Execute(SeederContext context)
}

context.GroupUsers.AddRange(groupUsers);

progress?.Report(new PhaseCompleted(Phase));
}

internal int[] ComputeUsersPerGroup(int groupCount, int userCount)
Expand Down
Loading
Loading