Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -0,0 +1,335 @@
using System.Text.Json;
namespace StellaOps.Policy.Storage.Postgres.Migration;
/// <summary>
/// Converts MongoDB policy documents (as JSON) to migration data transfer objects.
/// Task reference: PG-T4.9
/// </summary>
/// <remarks>
/// This converter handles the transformation of MongoDB document JSON exports
/// into DTOs suitable for PostgreSQL import. The caller is responsible for
/// exporting MongoDB documents as JSON before passing them to this converter.
/// </remarks>
public static class MongoDocumentConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Converts a MongoDB PolicyDocument (as JSON) to PackMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackMigrationData ConvertPackFromJson(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new PackMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
TenantId = GetString(root, "tenantId") ?? "",
Name = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing name"),
DisplayName = GetString(root, "displayName"),
Description = GetString(root, "description"),
ActiveVersion = GetNullableInt(root, "activeVersion"),
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
/// <summary>
/// Converts a MongoDB PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackVersionMigrationData ConvertVersionFromJson(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var status = GetString(root, "status") ?? "Draft";
var isPublished = status == "Active" || status == "Approved";
return new PackVersionMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
Version = GetInt(root, "version", 1),
Description = GetString(root, "description"),
RulesHash = GetString(root, "bundleDigest"),
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
/// <summary>
/// Creates a simple rule migration entry from raw Rego content.
/// </summary>
/// <param name="name">Rule name.</param>
/// <param name="content">Rego content.</param>
/// <param name="severity">Optional severity.</param>
/// <returns>Rule migration data.</returns>
public static RuleMigrationData CreateRuleFromContent(
string name,
string content,
string? severity = null)
{
return new RuleMigrationData
{
Name = name,
Content = content,
RuleType = "rego",
Severity = severity ?? "medium",
CreatedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Parses multiple pack documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of pack documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackMigrationData> ConvertPacksFromJsonArray(string jsonArray)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
using var doc = JsonDocument.Parse(jsonArray);
var results = new List<PackMigrationData>();
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException("Expected a JSON array", nameof(jsonArray));
}
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertPackElement(element));
}
return results;
}
/// <summary>
/// Parses multiple version documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of version documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackVersionMigrationData> ConvertVersionsFromJsonArray(string jsonArray)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
using var doc = JsonDocument.Parse(jsonArray);
var results = new List<PackVersionMigrationData>();
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException("Expected a JSON array", nameof(jsonArray));
}
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertVersionElement(element));
}
return results;
}
private static PackMigrationData ConvertPackElement(JsonElement root)
{
return new PackMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
TenantId = GetString(root, "tenantId") ?? "",
Name = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing name"),
DisplayName = GetString(root, "displayName"),
Description = GetString(root, "description"),
ActiveVersion = GetNullableInt(root, "activeVersion"),
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
private static PackVersionMigrationData ConvertVersionElement(JsonElement root)
{
var status = GetString(root, "status") ?? "Draft";
var isPublished = status == "Active" || status == "Approved";
return new PackVersionMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
Version = GetInt(root, "version", 1),
Description = GetString(root, "description"),
RulesHash = GetString(root, "bundleDigest"),
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
private static string? GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
private static int GetInt(JsonElement element, string propertyName, int defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
return prop.ValueKind == JsonValueKind.Number ? prop.GetInt32() : defaultValue;
}
private static int? GetNullableInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
if (prop.ValueKind == JsonValueKind.Null)
{
return null;
}
return prop.ValueKind == JsonValueKind.Number ? prop.GetInt32() : null;
}
private static bool GetBool(JsonElement element, string propertyName, bool defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
if (prop.ValueKind == JsonValueKind.True)
{
return true;
}
if (prop.ValueKind == JsonValueKind.False)
{
return false;
}
return defaultValue;
}
private static DateTimeOffset GetDateTimeOffset(JsonElement element, string propertyName, DateTimeOffset defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
if (prop.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(prop.GetString(), out var result))
{
return result;
}
// Handle MongoDB extended JSON date format {"$date": ...}
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
{
return dateResult;
}
if (dateProp.ValueKind == JsonValueKind.Number)
{
return DateTimeOffset.FromUnixTimeMilliseconds(dateProp.GetInt64());
}
}
return defaultValue;
}
private static DateTimeOffset? GetNullableDateTimeOffset(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
if (prop.ValueKind == JsonValueKind.Null)
{
return null;
}
if (prop.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(prop.GetString(), out var result))
{
return result;
}
// Handle MongoDB extended JSON date format
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
{
return dateResult;
}
if (dateProp.ValueKind == JsonValueKind.Number)
{
return DateTimeOffset.FromUnixTimeMilliseconds(dateProp.GetInt64());
}
}
return null;
}
private static string ExtractMetadata(JsonElement element)
{
var metadata = new Dictionary<string, object>();
if (element.TryGetProperty("tags", out var tagsProp) && tagsProp.ValueKind == JsonValueKind.Array)
{
var tags = new List<string>();
foreach (var tag in tagsProp.EnumerateArray())
{
if (tag.ValueKind == JsonValueKind.String)
{
tags.Add(tag.GetString()!);
}
}
if (tags.Count > 0)
{
metadata["tags"] = tags;
}
}
if (metadata.Count == 0)
{
return "{}";
}
return JsonSerializer.Serialize(metadata);
}
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Storage.Postgres.Migration;
/// <summary>
/// Handles migration of policy data from MongoDB to PostgreSQL.
/// Task references: PG-T4.9, PG-T4.10, PG-T4.11
/// </summary>
/// <remarks>
/// This migrator converts policy packs and their versions from MongoDB documents
/// to PostgreSQL entities while preserving version history and active version settings.
/// </remarks>
public sealed class PolicyMigrator
{
private readonly IPackRepository _packRepository;
private readonly IPackVersionRepository _versionRepository;
private readonly IRuleRepository _ruleRepository;
private readonly ILogger<PolicyMigrator> _logger;
public PolicyMigrator(
IPackRepository packRepository,
IPackVersionRepository versionRepository,
IRuleRepository ruleRepository,
ILogger<PolicyMigrator> logger)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_versionRepository = versionRepository ?? throw new ArgumentNullException(nameof(versionRepository));
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Imports a policy pack and its versions to PostgreSQL.
/// </summary>
/// <param name="pack">The pack data to import.</param>
/// <param name="versions">The pack versions to import.</param>
/// <param name="rules">Rules associated with each version, keyed by version number.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Migration result with success status and any errors.</returns>
public async Task<PackMigrationResult> ImportPackAsync(
PackMigrationData pack,
IReadOnlyList<PackVersionMigrationData> versions,
IReadOnlyDictionary<int, IReadOnlyList<RuleMigrationData>>? rules,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pack);
ArgumentNullException.ThrowIfNull(versions);
var result = new PackMigrationResult
{
PackId = pack.SourceId,
TenantId = pack.TenantId,
PackName = pack.Name
};
try
{
_logger.LogInformation(
"Starting migration of pack {PackId} ({PackName}) for tenant {TenantId}",
pack.SourceId, pack.Name, pack.TenantId);
// Check if pack already exists
var existingPack = await _packRepository.GetByNameAsync(pack.TenantId, pack.Name, cancellationToken);
if (existingPack is not null)
{
_logger.LogWarning(
"Pack {PackName} already exists in PostgreSQL for tenant {TenantId}, skipping",
pack.Name, pack.TenantId);
result.Skipped = true;
result.SkipReason = "Pack already exists";
return result;
}
// Create pack entity
var packEntity = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = pack.TenantId,
Name = pack.Name,
DisplayName = pack.DisplayName,
Description = pack.Description,
ActiveVersion = pack.ActiveVersion,
IsBuiltin = pack.IsBuiltin,
IsDeprecated = false,
Metadata = pack.Metadata ?? "{}",
CreatedAt = pack.CreatedAt,
UpdatedAt = pack.UpdatedAt,
CreatedBy = pack.CreatedBy
};
var createdPack = await _packRepository.CreateAsync(packEntity, cancellationToken);
result.PostgresPackId = createdPack.Id;
_logger.LogDebug("Created pack {PackId} in PostgreSQL", createdPack.Id);
// Import versions
foreach (var version in versions.OrderBy(v => v.Version))
{
var versionResult = await ImportVersionAsync(
createdPack.Id,
version,
rules?.GetValueOrDefault(version.Version),
cancellationToken);
result.VersionResults.Add(versionResult);
if (!versionResult.Success)
{
_logger.LogWarning(
"Failed to import version {Version} for pack {PackName}: {Error}",
version.Version, pack.Name, versionResult.ErrorMessage);
}
}
result.Success = result.VersionResults.All(v => v.Success || v.Skipped);
result.VersionsImported = result.VersionResults.Count(v => v.Success);
_logger.LogInformation(
"Completed migration of pack {PackName}: {VersionsImported} versions imported",
pack.Name, result.VersionsImported);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate pack {PackId} ({PackName})", pack.SourceId, pack.Name);
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
private async Task<VersionMigrationResult> ImportVersionAsync(
Guid packId,
PackVersionMigrationData version,
IReadOnlyList<RuleMigrationData>? rules,
CancellationToken cancellationToken)
{
var result = new VersionMigrationResult
{
Version = version.Version
};
try
{
// Check if version already exists
var existingVersion = await _versionRepository.GetByPackAndVersionAsync(packId, version.Version, cancellationToken);
if (existingVersion is not null)
{
result.Skipped = true;
result.SkipReason = "Version already exists";
return result;
}
var versionEntity = new PackVersionEntity
{
Id = Guid.NewGuid(),
PackId = packId,
Version = version.Version,
Description = version.Description,
RulesHash = version.RulesHash ?? ComputeRulesHash(rules),
IsPublished = version.IsPublished,
PublishedAt = version.PublishedAt,
PublishedBy = version.PublishedBy,
CreatedAt = version.CreatedAt,
CreatedBy = version.CreatedBy
};
var createdVersion = await _versionRepository.CreateAsync(versionEntity, cancellationToken);
result.PostgresVersionId = createdVersion.Id;
// Import rules if provided
if (rules is not null && rules.Count > 0)
{
foreach (var rule in rules)
{
var ruleEntity = new RuleEntity
{
Id = Guid.NewGuid(),
PackVersionId = createdVersion.Id,
Name = rule.Name,
Description = rule.Description,
Content = rule.Content,
RuleType = ParseRuleType(rule.RuleType),
ContentHash = rule.ContentHash ?? ComputeContentHash(rule.Content),
Severity = ParseSeverity(rule.Severity),
Category = rule.Category,
Tags = rule.Tags ?? [],
Metadata = rule.Metadata ?? "{}",
CreatedAt = rule.CreatedAt ?? DateTimeOffset.UtcNow
};
await _ruleRepository.CreateAsync(ruleEntity, cancellationToken);
result.RulesImported++;
}
}
result.Success = true;
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
/// <summary>
/// Verifies that migrated data matches between MongoDB and PostgreSQL.
/// </summary>
/// <param name="tenantId">Tenant to verify.</param>
/// <param name="expectedPacks">Expected pack count from MongoDB.</param>
/// <param name="expectedVersions">Expected version counts per pack.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<MigrationVerificationResult> VerifyMigrationAsync(
string tenantId,
int expectedPacks,
IReadOnlyDictionary<string, int>? expectedVersions,
CancellationToken cancellationToken = default)
{
var result = new MigrationVerificationResult
{
TenantId = tenantId
};
try
{
var packs = await _packRepository.GetAllAsync(
tenantId,
includeBuiltin: true,
includeDeprecated: true,
limit: 1000,
cancellationToken: cancellationToken);
result.ActualPackCount = packs.Count;
result.ExpectedPackCount = expectedPacks;
if (expectedVersions is not null)
{
foreach (var pack in packs)
{
var versions = await _versionRepository.GetByPackIdAsync(pack.Id, cancellationToken: cancellationToken);
result.ActualVersionCounts[pack.Name] = versions.Count;
if (expectedVersions.TryGetValue(pack.Name, out var expected))
{
result.ExpectedVersionCounts[pack.Name] = expected;
if (versions.Count != expected)
{
result.Discrepancies.Add(
$"Pack '{pack.Name}': expected {expected} versions, found {versions.Count}");
}
}
}
}
result.Success = result.ActualPackCount == expectedPacks && result.Discrepancies.Count == 0;
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
private static string ComputeRulesHash(IReadOnlyList<RuleMigrationData>? rules)
{
if (rules is null || rules.Count == 0)
{
return "empty";
}
var combined = string.Join("|", rules.OrderBy(r => r.Name).Select(r => r.ContentHash ?? r.Content));
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(combined))).ToLowerInvariant();
}
private static string ComputeContentHash(string content)
{
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant();
}
private static RuleType ParseRuleType(string? ruleType)
{
return ruleType?.ToLowerInvariant() switch
{
"rego" => RuleType.Rego,
"json" => RuleType.Json,
"yaml" => RuleType.Yaml,
_ => RuleType.Rego
};
}
private static RuleSeverity ParseSeverity(string? severity)
{
return severity?.ToLowerInvariant() switch
{
"critical" => RuleSeverity.Critical,
"high" => RuleSeverity.High,
"medium" => RuleSeverity.Medium,
"low" => RuleSeverity.Low,
"info" => RuleSeverity.Info,
_ => RuleSeverity.Medium
};
}
}
/// <summary>
/// Data transfer object for pack migration.
/// </summary>
public sealed class PackMigrationData
{
/// <summary>Source system identifier (MongoDB _id).</summary>
public required string SourceId { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Pack name.</summary>
public required string Name { get; init; }
/// <summary>Display name.</summary>
public string? DisplayName { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Currently active version.</summary>
public int? ActiveVersion { get; init; }
/// <summary>Latest version number.</summary>
public int LatestVersion { get; init; }
/// <summary>Whether this is a built-in pack.</summary>
public bool IsBuiltin { get; init; }
/// <summary>Metadata JSON.</summary>
public string? Metadata { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Last update timestamp.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Creator.</summary>
public string? CreatedBy { get; init; }
}
/// <summary>
/// Data transfer object for pack version migration.
/// </summary>
public sealed class PackVersionMigrationData
{
/// <summary>Source system identifier.</summary>
public required string SourceId { get; init; }
/// <summary>Version number.</summary>
public required int Version { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Hash of rules in this version.</summary>
public string? RulesHash { get; init; }
/// <summary>Whether published.</summary>
public bool IsPublished { get; init; }
/// <summary>Publish timestamp.</summary>
public DateTimeOffset? PublishedAt { get; init; }
/// <summary>Publisher.</summary>
public string? PublishedBy { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Creator.</summary>
public string? CreatedBy { get; init; }
}
/// <summary>
/// Data transfer object for rule migration.
/// </summary>
public sealed class RuleMigrationData
{
/// <summary>Rule name.</summary>
public required string Name { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Rule content (Rego, JSON, or YAML).</summary>
public required string Content { get; init; }
/// <summary>Rule type (rego, json, yaml).</summary>
public string? RuleType { get; init; }
/// <summary>Content hash.</summary>
public string? ContentHash { get; init; }
/// <summary>Severity level.</summary>
public string? Severity { get; init; }
/// <summary>Category.</summary>
public string? Category { get; init; }
/// <summary>Tags.</summary>
public string[]? Tags { get; init; }
/// <summary>Metadata JSON.</summary>
public string? Metadata { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Result of pack migration operation.
/// </summary>
public sealed class PackMigrationResult
{
public required string PackId { get; init; }
public required string TenantId { get; init; }
public required string PackName { get; init; }
public Guid? PostgresPackId { get; set; }
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? ErrorMessage { get; set; }
public int VersionsImported { get; set; }
public List<VersionMigrationResult> VersionResults { get; } = [];
}
/// <summary>
/// Result of version migration operation.
/// </summary>
public sealed class VersionMigrationResult
{
public required int Version { get; init; }
public Guid? PostgresVersionId { get; set; }
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? ErrorMessage { get; set; }
public int RulesImported { get; set; }
}
/// <summary>
/// Result of migration verification.
/// </summary>
public sealed class MigrationVerificationResult
{
public required string TenantId { get; init; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public int ExpectedPackCount { get; set; }
public int ActualPackCount { get; set; }
public Dictionary<string, int> ExpectedVersionCounts { get; } = [];
public Dictionary<string, int> ActualVersionCounts { get; } = [];
public List<string> Discrepancies { get; } = [];
}