save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user