save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
@@ -8,14 +11,25 @@ namespace StellaOps.AirGap.Persistence.EfCore.Context;
/// </summary>
public class AirGapDbContext : DbContext
{
private readonly string _schemaName;
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
: this(options, null)
{
}
public AirGapDbContext(DbContextOptions<AirGapDbContext> options, IOptions<PostgresOptions>? postgresOptions)
: base(options)
{
var schema = postgresOptions?.Value.SchemaName;
_schemaName = string.IsNullOrWhiteSpace(schema)
? AirGapDataSource.DefaultSchemaName
: schema;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("airgap");
modelBuilder.HasDefaultSchema(_schemaName);
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -1,10 +1,13 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Options;
using Npgsql;
namespace StellaOps.AirGap.Persistence.Extensions;
@@ -23,6 +26,7 @@ public static class AirGapPersistenceExtensions
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<AirGapDataSource>();
services.AddHostedService(sp => CreateMigrationHost(sp));
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
@@ -38,9 +42,46 @@ public static class AirGapPersistenceExtensions
{
services.Configure(configureOptions);
services.AddSingleton<AirGapDataSource>();
services.AddHostedService(sp => CreateMigrationHost(sp));
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
return services;
}
private static IHostedService CreateMigrationHost(IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
var schemaName = string.IsNullOrWhiteSpace(options.SchemaName)
? AirGapDataSource.DefaultSchemaName
: options.SchemaName!;
var connectionString = BuildMigrationConnectionString(options, schemaName);
var logger = serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Migration.AirGap.Persistence");
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
return new AirGapStartupMigrationHost(
connectionString,
schemaName,
"AirGap.Persistence",
typeof(AirGapDataSource).Assembly,
logger,
lifetime);
}
private static string BuildMigrationConnectionString(PostgresOptions options, string schemaName)
{
var builder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
{
CommandTimeout = options.CommandTimeoutSeconds
};
if (!string.IsNullOrWhiteSpace(schemaName))
{
builder.SearchPath = $"{schemaName}, public";
}
return builder.ConnectionString;
}
}

View File

@@ -0,0 +1,21 @@
using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.AirGap.Persistence.Postgres;
internal sealed class AirGapStartupMigrationHost : StartupMigrationHost
{
public AirGapStartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options = null)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -26,25 +26,47 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
var tenantKey = NormalizeTenantId(tenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", cancellationToken).ConfigureAwait(false);
var sql = $$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM state
WHERE LOWER(tenant_id) = LOWER(@tenant_id);
FROM {{stateTable}}
WHERE tenant_id = @tenant_id;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "tenant_id", tenantKey);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
{
// Return default state for tenant if not found
return new AirGapState { TenantId = tenantId };
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return Map(reader);
}
}
return Map(reader);
// Fallback for legacy rows stored without normalization.
await using var fallbackCommand = CreateCommand($$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM {{stateTable}}
WHERE LOWER(tenant_id) = LOWER(@tenant_id)
ORDER BY updated_at DESC, id DESC
LIMIT 1;
""", connection);
AddParameter(fallbackCommand, "tenant_id", tenantId);
await using var fallbackReader = await fallbackCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await fallbackReader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return Map(fallbackReader);
}
// Return default state for tenant if not found
return new AirGapState { TenantId = tenantId };
}
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
@@ -52,9 +74,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
ArgumentNullException.ThrowIfNull(state);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO state (
var tenantKey = NormalizeTenantId(state.TenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", cancellationToken).ConfigureAwait(false);
var sql = $$"""
INSERT INTO {{stateTable}} (
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
)
@@ -76,7 +101,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", state.Id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "sealed", state.Sealed);
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
@@ -88,7 +113,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static AirGapState Map(NpgsqlDataReader reader)
private AirGapState Map(NpgsqlDataReader reader)
{
var id = reader.GetString(0);
var tenantId = reader.GetString(1);
@@ -133,7 +158,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return JsonSerializer.Serialize(obj);
}
private static TimeAnchor DeserializeTimeAnchor(string json)
private TimeAnchor DeserializeTimeAnchor(string json)
{
try
{
@@ -152,8 +177,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return new TimeAnchor(anchorTime, source, format, signatureFingerprint, tokenDigest);
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse time anchor JSON; using default.");
return TimeAnchor.Unknown;
}
}
@@ -168,7 +194,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return JsonSerializer.Serialize(obj);
}
private static StalenessBudget DeserializeStalenessBudget(string json)
private StalenessBudget DeserializeStalenessBudget(string json)
{
try
{
@@ -180,8 +206,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return new StalenessBudget(warningSeconds, breachSeconds);
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse staleness budget JSON; using default.");
return StalenessBudget.Default;
}
}
@@ -193,14 +220,20 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return "{}";
}
var dict = budgets.ToDictionary(
kv => kv.Key,
kv => new { warningSeconds = kv.Value.WarningSeconds, breachSeconds = kv.Value.BreachSeconds });
var dict = new SortedDictionary<string, object>(StringComparer.Ordinal);
foreach (var kv in budgets.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
dict[kv.Key] = new
{
warningSeconds = kv.Value.WarningSeconds,
breachSeconds = kv.Value.BreachSeconds
};
}
return JsonSerializer.Serialize(dict);
}
private static IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
private IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
@@ -221,8 +254,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return result;
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse content budgets JSON; using defaults.");
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -245,29 +279,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
}
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var schemaName = DataSource.SchemaName ?? "public";
var quotedSchema = QuoteIdentifier(schemaName);
var sql = $$"""
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.state (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL PRIMARY KEY,
sealed BOOLEAN NOT NULL DEFAULT FALSE,
policy_hash TEXT,
time_anchor JSONB NOT NULL DEFAULT '{}'::jsonb,
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
staleness_budget JSONB NOT NULL DEFAULT '{"warningSeconds":3600,"breachSeconds":7200}'::jsonb,
drift_baseline_seconds BIGINT NOT NULL DEFAULT 0,
content_budgets JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_airgap_state_tenant ON {{quotedSchema}}.state(tenant_id);
CREATE INDEX IF NOT EXISTS idx_airgap_state_sealed ON {{quotedSchema}}.state(sealed) WHERE sealed = TRUE;
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var schemaName = GetSchemaName();
if (!await TableExistsAsync(connection, schemaName, "state", cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
$"AirGap state table missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
}
_initialized = true;
}
finally
@@ -276,6 +293,46 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
}
}
private async Task<bool> TableExistsAsync(
NpgsqlConnection connection,
string schemaName,
string tableName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = @schema AND table_name = @table
);
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "schema", schemaName);
AddParameter(command, "table", tableName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private string GetQualifiedTableName(string tableName)
{
var schema = GetSchemaName();
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
{
return DataSource.SchemaName!;
}
return AirGapDataSource.DefaultSchemaName;
}
private static string NormalizeTenantId(string tenantId) => tenantId.Trim().ToLowerInvariant();
private static string QuoteIdentifier(string identifier)
{
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);

View File

@@ -31,11 +31,12 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string sql = """
var versionTable = GetQualifiedTableName("bundle_versions");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM bundle_versions
FROM {{versionTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
""";
@@ -55,11 +56,13 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(record.TenantId);
var bundleTypeKey = NormalizeKey(record.BundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", ct).ConfigureAwait(false);
var versionTable = GetQualifiedTableName("bundle_versions");
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
const string closeHistorySql = """
UPDATE bundle_version_history
var closeHistorySql = $$"""
UPDATE {{historyTable}}
SET deactivated_at = @activated_at
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
""";
@@ -73,8 +76,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string historySql = """
INSERT INTO bundle_version_history (
var historySql = $$"""
INSERT INTO {{historyTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, deactivated_at, was_force_activated, force_activate_reason
)
@@ -102,8 +105,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string upsertSql = """
INSERT INTO bundle_versions (
var upsertSql = $$"""
INSERT INTO {{versionTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
)
@@ -165,13 +168,14 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string sql = """
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM bundle_version_history
FROM {{historyTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
ORDER BY activated_at DESC
ORDER BY activated_at DESC, id DESC
LIMIT @limit;
""";
@@ -236,56 +240,15 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
}
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
var schemaName = DataSource.SchemaName ?? "public";
var quotedSchema = QuoteIdentifier(schemaName);
var sql = $$"""
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
var schemaName = GetSchemaName();
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_versions (
tenant_id TEXT NOT NULL,
bundle_type TEXT NOT NULL,
version_string TEXT NOT NULL,
major INTEGER NOT NULL,
minor INTEGER NOT NULL,
patch INTEGER NOT NULL,
prerelease TEXT,
bundle_created_at TIMESTAMPTZ NOT NULL,
bundle_digest TEXT NOT NULL,
activated_at TIMESTAMPTZ NOT NULL,
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
force_activate_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, bundle_type)
);
if (!await TableExistsAsync(connection, schemaName, "bundle_versions", ct).ConfigureAwait(false) ||
!await TableExistsAsync(connection, schemaName, "bundle_version_history", ct).ConfigureAwait(false))
{
throw new InvalidOperationException(
$"AirGap bundle version tables missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
}
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_versions_tenant
ON {{quotedSchema}}.bundle_versions(tenant_id);
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_version_history (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
bundle_type TEXT NOT NULL,
version_string TEXT NOT NULL,
major INTEGER NOT NULL,
minor INTEGER NOT NULL,
patch INTEGER NOT NULL,
prerelease TEXT,
bundle_created_at TIMESTAMPTZ NOT NULL,
bundle_digest TEXT NOT NULL,
activated_at TIMESTAMPTZ NOT NULL,
deactivated_at TIMESTAMPTZ,
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
force_activate_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_version_history_tenant
ON {{quotedSchema}}.bundle_version_history(tenant_id, bundle_type, activated_at DESC);
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_initialized = true;
}
finally
@@ -294,6 +257,44 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
}
}
private async Task<bool> TableExistsAsync(
NpgsqlConnection connection,
string schemaName,
string tableName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = @schema AND table_name = @table
);
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "schema", schemaName);
AddParameter(command, "table", tableName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private string GetQualifiedTableName(string tableName)
{
var schema = GetSchemaName();
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
{
return DataSource.SchemaName!;
}
return AirGapDataSource.DefaultSchemaName;
}
private static string NormalizeKey(string value) => value.Trim().ToLowerInvariant();
private static string QuoteIdentifier(string identifier)

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0028-M | DONE | Maintainability audit for StellaOps.AirGap.Persistence. |
| AUDIT-0028-T | DONE | Test coverage audit for StellaOps.AirGap.Persistence. |
| AUDIT-0028-A | TODO | Pending approval for changes. |
| AUDIT-0028-A | DONE | Applied schema + determinism fixes and migration host wiring. |