Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a dependency between migrations in different modules.
|
||||
/// </summary>
|
||||
public sealed record MigrationDependency
|
||||
{
|
||||
/// <summary>
|
||||
/// The module that has the dependency.
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The migration file that has the dependency.
|
||||
/// </summary>
|
||||
public required string Migration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The module being depended upon.
|
||||
/// </summary>
|
||||
public required string DependsOnModule { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The schema being depended upon.
|
||||
/// </summary>
|
||||
public required string DependsOnSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The specific table or object being depended upon (optional).
|
||||
/// </summary>
|
||||
public string? DependsOnObject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a soft dependency (FK created conditionally).
|
||||
/// </summary>
|
||||
public bool IsSoft { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of why this dependency exists.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Module schema configuration for dependency resolution.
|
||||
/// </summary>
|
||||
public sealed record ModuleSchemaConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// The module name (e.g., "Authority", "Concelier").
|
||||
/// </summary>
|
||||
public required string Module { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The PostgreSQL schema name (e.g., "auth", "vuln").
|
||||
/// </summary>
|
||||
public required string Schema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The WebService that owns this module's migrations.
|
||||
/// </summary>
|
||||
public string? OwnerService { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The assembly containing migrations for this module.
|
||||
/// </summary>
|
||||
public string? MigrationAssembly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of module schemas and their dependencies.
|
||||
/// </summary>
|
||||
public sealed class ModuleDependencyRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ModuleSchemaConfig> _modules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<MigrationDependency> _dependencies = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered modules.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ModuleSchemaConfig> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered dependencies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> Dependencies => _dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a module schema configuration.
|
||||
/// </summary>
|
||||
public ModuleDependencyRegistry RegisterModule(ModuleSchemaConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_modules[config.Module] = config;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a dependency between modules.
|
||||
/// </summary>
|
||||
public ModuleDependencyRegistry RegisterDependency(MigrationDependency dependency)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependency);
|
||||
_dependencies.Add(dependency);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name for a module.
|
||||
/// </summary>
|
||||
public string? GetSchemaForModule(string moduleName)
|
||||
{
|
||||
return _modules.TryGetValue(moduleName, out var config) ? config.Schema : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for a schema.
|
||||
/// </summary>
|
||||
public string? GetModuleForSchema(string schemaName)
|
||||
{
|
||||
return _modules.Values
|
||||
.FirstOrDefault(m => string.Equals(m.Schema, schemaName, StringComparison.OrdinalIgnoreCase))
|
||||
?.Module;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets dependencies for a specific module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> GetDependenciesForModule(string moduleName)
|
||||
{
|
||||
return _dependencies
|
||||
.Where(d => string.Equals(d.Module, moduleName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules that depend on a specific module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MigrationDependency> GetDependentsOfModule(string moduleName)
|
||||
{
|
||||
return _dependencies
|
||||
.Where(d => string.Equals(d.DependsOnModule, moduleName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that all dependencies can be satisfied.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ValidateDependencies()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var dep in _dependencies)
|
||||
{
|
||||
// Check that the dependent module exists
|
||||
if (!_modules.ContainsKey(dep.Module))
|
||||
{
|
||||
errors.Add($"Unknown module '{dep.Module}' in dependency declaration.");
|
||||
}
|
||||
|
||||
// Check that the target module exists
|
||||
if (!_modules.ContainsKey(dep.DependsOnModule))
|
||||
{
|
||||
errors.Add($"Unknown dependency target module '{dep.DependsOnModule}' from '{dep.Module}'.");
|
||||
}
|
||||
|
||||
// Check that the target schema matches
|
||||
if (_modules.TryGetValue(dep.DependsOnModule, out var targetConfig))
|
||||
{
|
||||
if (!string.Equals(targetConfig.Schema, dep.DependsOnSchema, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(
|
||||
$"Schema mismatch for dependency '{dep.Module}' -> '{dep.DependsOnModule}': " +
|
||||
$"expected schema '{targetConfig.Schema}', got '{dep.DependsOnSchema}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default registry with all StellaOps modules.
|
||||
/// </summary>
|
||||
public static ModuleDependencyRegistry CreateDefault()
|
||||
{
|
||||
var registry = new ModuleDependencyRegistry();
|
||||
|
||||
// Register all modules with their schemas
|
||||
registry
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Authority", Schema = "auth", OwnerService = "Authority.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Concelier", Schema = "vuln", OwnerService = "Concelier.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Excititor", Schema = "vex", OwnerService = "Excititor.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Policy", Schema = "policy", OwnerService = "Policy.Gateway" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Scheduler", Schema = "scheduler", OwnerService = "Scheduler.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Notify", Schema = "notify", OwnerService = "Notify.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Scanner", Schema = "scanner", OwnerService = "Scanner.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Attestor", Schema = "proofchain", OwnerService = "Attestor.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Signer", Schema = "signer", OwnerService = "Signer.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Signals", Schema = "signals", OwnerService = "Signals" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "EvidenceLocker", Schema = "evidence", OwnerService = "EvidenceLocker.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "ExportCenter", Schema = "export", OwnerService = "ExportCenter.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "IssuerDirectory", Schema = "issuer", OwnerService = "IssuerDirectory.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Orchestrator", Schema = "orchestrator", OwnerService = "Orchestrator.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Findings", Schema = "findings", OwnerService = "Findings.Ledger.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "BinaryIndex", Schema = "binaries", OwnerService = "Scanner.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "VexHub", Schema = "vexhub", OwnerService = "VexHub.WebService" })
|
||||
.RegisterModule(new ModuleSchemaConfig { Module = "Unknowns", Schema = "unknowns", OwnerService = "Policy.Gateway" });
|
||||
|
||||
// Register known cross-module dependencies
|
||||
registry
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Signer",
|
||||
Migration = "20251214000001_AddKeyManagementSchema.sql",
|
||||
DependsOnModule = "Attestor",
|
||||
DependsOnSchema = "proofchain",
|
||||
DependsOnObject = "trust_anchors",
|
||||
IsSoft = true,
|
||||
Description = "Optional FK from signer.key_history to proofchain.trust_anchors"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Scanner",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Concelier",
|
||||
DependsOnSchema = "vuln",
|
||||
IsSoft = true,
|
||||
Description = "Scanner uses Concelier linksets for advisory data"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Policy",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Concelier",
|
||||
DependsOnSchema = "vuln",
|
||||
IsSoft = true,
|
||||
Description = "Policy uses vulnerability data from Concelier"
|
||||
})
|
||||
.RegisterDependency(new MigrationDependency
|
||||
{
|
||||
Module = "Policy",
|
||||
Migration = "N/A",
|
||||
DependsOnModule = "Excititor",
|
||||
DependsOnSchema = "vex",
|
||||
IsSoft = true,
|
||||
Description = "Policy uses VEX data from Excititor"
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the registry to JSON.
|
||||
/// </summary>
|
||||
public string ToJson()
|
||||
{
|
||||
var data = new
|
||||
{
|
||||
modules = _modules.Values.ToList(),
|
||||
dependencies = _dependencies
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry instrumentation for database migrations.
|
||||
/// </summary>
|
||||
public static class MigrationTelemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the activity source for migration tracing.
|
||||
/// </summary>
|
||||
public const string ActivitySourceName = "StellaOps.Infrastructure.Postgres.Migrations";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the meter for migration metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.Infrastructure.Postgres.Migrations";
|
||||
|
||||
private static readonly ActivitySource ActivitySource = new(ActivitySourceName, "1.0.0");
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
// Metrics
|
||||
private static readonly Counter<long> MigrationsAppliedCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.applied.total",
|
||||
description: "Total number of migrations applied");
|
||||
|
||||
private static readonly Counter<long> MigrationsFailedCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.failed.total",
|
||||
description: "Total number of migration failures");
|
||||
|
||||
private static readonly Histogram<double> MigrationDurationHistogram = Meter.CreateHistogram<double>(
|
||||
"stellaops.migrations.duration.seconds",
|
||||
unit: "s",
|
||||
description: "Duration of migration execution");
|
||||
|
||||
private static readonly Counter<long> LockAcquiredCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.lock.acquired.total",
|
||||
description: "Total number of advisory locks acquired");
|
||||
|
||||
private static readonly Counter<long> LockTimeoutCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.migrations.lock.timeout.total",
|
||||
description: "Total number of advisory lock timeouts");
|
||||
|
||||
private static readonly UpDownCounter<int> PendingMigrationsGauge = Meter.CreateUpDownCounter<int>(
|
||||
"stellaops.migrations.pending.count",
|
||||
description: "Number of pending migrations");
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for migration execution.
|
||||
/// </summary>
|
||||
public static Activity? StartMigrationRun(string moduleName, string schemaName, int pendingCount)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.run", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.schema", schemaName);
|
||||
activity.SetTag("migration.pending_count", pendingCount);
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
PendingMigrationsGauge.Add(pendingCount, new KeyValuePair<string, object?>("module", moduleName));
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for a single migration.
|
||||
/// </summary>
|
||||
public static Activity? StartMigrationApply(string moduleName, string migrationName, MigrationCategory category)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.apply", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.name", migrationName);
|
||||
activity.SetTag("migration.category", category.ToString().ToLowerInvariant());
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an activity for advisory lock acquisition.
|
||||
/// </summary>
|
||||
public static Activity? StartLockAcquisition(string moduleName, string schemaName)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("migration.lock.acquire", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag("migration.module", moduleName);
|
||||
activity.SetTag("migration.schema", schemaName);
|
||||
activity.SetTag("db.system", "postgresql");
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful migration application.
|
||||
/// </summary>
|
||||
public static void RecordMigrationApplied(
|
||||
string moduleName,
|
||||
string migrationName,
|
||||
MigrationCategory category,
|
||||
double durationSeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName },
|
||||
{ "category", category.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
MigrationsAppliedCounter.Add(1, tags);
|
||||
MigrationDurationHistogram.Record(durationSeconds, tags);
|
||||
PendingMigrationsGauge.Add(-1, new KeyValuePair<string, object?>("module", moduleName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a migration failure.
|
||||
/// </summary>
|
||||
public static void RecordMigrationFailed(
|
||||
string moduleName,
|
||||
string migrationName,
|
||||
MigrationCategory category,
|
||||
string errorCode)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName },
|
||||
{ "category", category.ToString().ToLowerInvariant() },
|
||||
{ "error.code", errorCode }
|
||||
};
|
||||
|
||||
MigrationsFailedCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful lock acquisition.
|
||||
/// </summary>
|
||||
public static void RecordLockAcquired(string moduleName, string schemaName, double waitSeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "schema", schemaName }
|
||||
};
|
||||
|
||||
LockAcquiredCounter.Add(1, tags);
|
||||
|
||||
// Also record wait time as part of histogram
|
||||
Meter.CreateHistogram<double>("stellaops.migrations.lock.wait.seconds", unit: "s")
|
||||
.Record(waitSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a lock acquisition timeout.
|
||||
/// </summary>
|
||||
public static void RecordLockTimeout(string moduleName, string schemaName)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "schema", schemaName }
|
||||
};
|
||||
|
||||
LockTimeoutCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a checksum validation error.
|
||||
/// </summary>
|
||||
public static void RecordChecksumError(string moduleName, string migrationName)
|
||||
{
|
||||
Meter.CreateCounter<long>("stellaops.migrations.checksum.errors.total")
|
||||
.Add(1, new TagList
|
||||
{
|
||||
{ "module", moduleName },
|
||||
{ "migration", migrationName }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error on an activity.
|
||||
/// </summary>
|
||||
public static void SetActivityError(Activity? activity, Exception exception)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
|
||||
activity.SetTag("error.type", exception.GetType().FullName);
|
||||
activity.SetTag("error.message", exception.Message);
|
||||
activity.SetTag("exception.stacktrace", exception.StackTrace);
|
||||
|
||||
// Add exception event for OpenTelemetry compatibility
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ "exception.type", exception.GetType().FullName },
|
||||
{ "exception.message", exception.Message }
|
||||
};
|
||||
activity.AddEvent(new ActivityEvent("exception", tags: tags));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an activity as successful.
|
||||
/// </summary>
|
||||
public static void SetActivitySuccess(Activity? activity, int appliedCount)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
activity.SetTag("migration.applied_count", appliedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Validates migration files for naming conventions, duplicates, and ordering issues.
|
||||
/// </summary>
|
||||
public static partial class MigrationValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard migration pattern: NNN_description.sql (001-099 for startup, 100+ for release).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex StandardPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Seed migration pattern: SNNN_description.sql.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^S(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex SeedPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Data migration pattern: DMNNN_description.sql.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^DM(\d{3})_[a-z0-9_]+\.sql$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex DataMigrationPattern();
|
||||
|
||||
/// <summary>
|
||||
/// Validation result for a set of migrations.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
public IReadOnlyList<ValidationError> Errors { get; init; } = [];
|
||||
public IReadOnlyList<ValidationWarning> Warnings { get; init; } = [];
|
||||
|
||||
public static ValidationResult Success(IReadOnlyList<ValidationWarning>? warnings = null) =>
|
||||
new() { Warnings = warnings ?? [] };
|
||||
|
||||
public static ValidationResult Failed(IReadOnlyList<ValidationError> errors, IReadOnlyList<ValidationWarning>? warnings = null) =>
|
||||
new() { Errors = errors, Warnings = warnings ?? [] };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error that will block migration execution.
|
||||
/// </summary>
|
||||
public sealed record ValidationError(string Code, string Message, string? MigrationName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validation warning that should be addressed but won't block execution.
|
||||
/// </summary>
|
||||
public sealed record ValidationWarning(string Code, string Message, string? MigrationName = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a collection of migration file names.
|
||||
/// </summary>
|
||||
public static ValidationResult Validate(IEnumerable<string> migrationNames)
|
||||
{
|
||||
var names = migrationNames.ToList();
|
||||
var errors = new List<ValidationError>();
|
||||
var warnings = new List<ValidationWarning>();
|
||||
|
||||
// Check for duplicates (same numeric prefix)
|
||||
var duplicates = DetectDuplicatePrefixes(names);
|
||||
foreach (var (prefix, duplicateNames) in duplicates)
|
||||
{
|
||||
errors.Add(new ValidationError(
|
||||
"DUPLICATE_PREFIX",
|
||||
$"Multiple migrations with prefix '{prefix}': {string.Join(", ", duplicateNames)}",
|
||||
duplicateNames.First()));
|
||||
}
|
||||
|
||||
// Check naming conventions
|
||||
foreach (var name in names)
|
||||
{
|
||||
var conventionResult = ValidateNamingConvention(name);
|
||||
if (conventionResult is not null)
|
||||
{
|
||||
if (conventionResult.Value.IsError)
|
||||
{
|
||||
errors.Add(new ValidationError(conventionResult.Value.Code, conventionResult.Value.Message, name));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add(new ValidationWarning(conventionResult.Value.Code, conventionResult.Value.Message, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gaps in numbering
|
||||
var gaps = DetectNumberingGaps(names);
|
||||
foreach (var gap in gaps)
|
||||
{
|
||||
warnings.Add(new ValidationWarning(
|
||||
"NUMBERING_GAP",
|
||||
$"Gap in migration numbering: {gap.After} is followed by {gap.Before} (missing {gap.Missing})",
|
||||
gap.Before));
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidationResult.Failed(errors, warnings)
|
||||
: ValidationResult.Success(warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects migrations with duplicate numeric prefixes.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<(string Prefix, IReadOnlyList<string> Names)> DetectDuplicatePrefixes(
|
||||
IEnumerable<string> migrationNames)
|
||||
{
|
||||
var byPrefix = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var name in migrationNames)
|
||||
{
|
||||
var prefix = ExtractNumericPrefix(name);
|
||||
if (prefix is null) continue;
|
||||
|
||||
if (!byPrefix.TryGetValue(prefix, out var list))
|
||||
{
|
||||
list = [];
|
||||
byPrefix[prefix] = list;
|
||||
}
|
||||
list.Add(name);
|
||||
}
|
||||
|
||||
return byPrefix
|
||||
.Where(kvp => kvp.Value.Count > 1)
|
||||
.Select(kvp => (kvp.Key, (IReadOnlyList<string>)kvp.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the numeric prefix from a migration name.
|
||||
/// </summary>
|
||||
public static string? ExtractNumericPrefix(string migrationName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(migrationName);
|
||||
|
||||
// Handle seed migrations (S001, S002, etc.)
|
||||
if (name.StartsWith('S') && char.IsDigit(name.ElementAtOrDefault(1)))
|
||||
{
|
||||
return "S" + new string(name.Skip(1).TakeWhile(char.IsDigit).ToArray());
|
||||
}
|
||||
|
||||
// Handle data migrations (DM001, DM002, etc.)
|
||||
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(2)))
|
||||
{
|
||||
return "DM" + new string(name.Skip(2).TakeWhile(char.IsDigit).ToArray());
|
||||
}
|
||||
|
||||
// Handle standard migrations (001, 002, etc.)
|
||||
var digits = new string(name.TakeWhile(char.IsDigit).ToArray());
|
||||
return string.IsNullOrEmpty(digits) ? null : digits.TrimStart('0').PadLeft(3, '0');
|
||||
}
|
||||
|
||||
private static (bool IsError, string Code, string Message)? ValidateNamingConvention(string migrationName)
|
||||
{
|
||||
var name = Path.GetFileName(migrationName);
|
||||
|
||||
// Check standard pattern
|
||||
if (StandardPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check seed pattern
|
||||
if (SeedPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check data migration pattern
|
||||
if (DataMigrationPattern().IsMatch(name))
|
||||
{
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
// Check for non-standard but common patterns
|
||||
if (name.StartsWith("V", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (false, "FLYWAY_STYLE", $"Migration '{name}' uses Flyway-style naming. Consider standardizing to NNN_description.sql format.");
|
||||
}
|
||||
|
||||
if (name.Length > 15 && char.IsDigit(name[0]) && name.Contains("_"))
|
||||
{
|
||||
// Likely EF Core timestamp pattern like 20251214000001_AddSchema.sql
|
||||
return (false, "EFCORE_STYLE", $"Migration '{name}' uses EF Core timestamp naming. Consider standardizing to NNN_description.sql format.");
|
||||
}
|
||||
|
||||
// Check for 4-digit prefixes (like 0059_scans_table.sql)
|
||||
var fourDigitMatch = System.Text.RegularExpressions.Regex.Match(name, @"^(\d{4})_");
|
||||
if (fourDigitMatch.Success)
|
||||
{
|
||||
return (false, "FOUR_DIGIT_PREFIX", $"Migration '{name}' uses 4-digit prefix. Standard is 3-digit (NNN_description.sql).");
|
||||
}
|
||||
|
||||
return (false, "NON_STANDARD_NAME", $"Migration '{name}' does not match standard naming pattern (NNN_description.sql).");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string After, string Before, string Missing)> DetectNumberingGaps(
|
||||
IEnumerable<string> migrationNames)
|
||||
{
|
||||
var gaps = new List<(string, string, string)>();
|
||||
var standardMigrations = new List<(int Number, string Name)>();
|
||||
|
||||
foreach (var name in migrationNames)
|
||||
{
|
||||
var prefix = ExtractNumericPrefix(name);
|
||||
if (prefix is null) continue;
|
||||
|
||||
// Only check standard migrations (not S or DM)
|
||||
if (prefix.StartsWith('S') || prefix.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (int.TryParse(prefix, out var num))
|
||||
{
|
||||
standardMigrations.Add((num, name));
|
||||
}
|
||||
}
|
||||
|
||||
var sorted = standardMigrations.OrderBy(m => m.Number).ToList();
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var prev = sorted[i - 1];
|
||||
var curr = sorted[i];
|
||||
var expected = prev.Number + 1;
|
||||
|
||||
if (curr.Number > expected && curr.Number - prev.Number > 1)
|
||||
{
|
||||
var missing = expected == curr.Number - 1
|
||||
? expected.ToString("D3")
|
||||
: $"{expected:D3}-{(curr.Number - 1):D3}";
|
||||
gaps.Add((prev.Name, curr.Name, missing));
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user