Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

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

View File

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

View File

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

View File

@@ -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>