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,21 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for AirGap module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// </summary>
public class AirGapDbContext : DbContext
{
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("airgap");
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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;
namespace StellaOps.AirGap.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring AirGap persistence services.
/// </summary>
public static class AirGapPersistenceExtensions
{
/// <summary>
/// Adds AirGap PostgreSQL persistence services.
/// </summary>
public static IServiceCollection AddAirGapPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:AirGap")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<AirGapDataSource>();
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
return services;
}
/// <summary>
/// Adds AirGap PostgreSQL persistence services with explicit options.
/// </summary>
public static IServiceCollection AddAirGapPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<AirGapDataSource>();
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
return services;
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AirGap.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for AirGap module.
/// </summary>
public sealed class AirGapDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for AirGap tables.
/// </summary>
public const string DefaultSchemaName = "airgap";
/// <summary>
/// Creates a new AirGap data source.
/// </summary>
public AirGapDataSource(IOptions<PostgresOptions> options, ILogger<AirGapDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "AirGap";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,275 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for AirGap sealing state.
/// </summary>
public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>, IAirGapStateStore
{
private volatile bool _initialized;
private readonly SemaphoreSlim _initLock = new(1, 1);
public PostgresAirGapStateStore(AirGapDataSource dataSource, ILogger<PostgresAirGapStateStore> logger)
: base(dataSource, logger)
{
}
public async Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM airgap.state
WHERE LOWER(tenant_id) = LOWER(@tenant_id);
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
// Return default state for tenant if not found
return new AirGapState { TenantId = tenantId };
}
return Map(reader);
}
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
{
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 airgap.state (
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
)
VALUES (
@id, @tenant_id, @sealed, @policy_hash, @time_anchor, @last_transition_at,
@staleness_budget, @drift_baseline_seconds, @content_budgets
)
ON CONFLICT (tenant_id) DO UPDATE SET
id = EXCLUDED.id,
sealed = EXCLUDED.sealed,
policy_hash = EXCLUDED.policy_hash,
time_anchor = EXCLUDED.time_anchor,
last_transition_at = EXCLUDED.last_transition_at,
staleness_budget = EXCLUDED.staleness_budget,
drift_baseline_seconds = EXCLUDED.drift_baseline_seconds,
content_budgets = EXCLUDED.content_budgets,
updated_at = NOW();
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", state.Id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "sealed", state.Sealed);
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
AddParameter(command, "last_transition_at", state.LastTransitionAt);
AddJsonbParameter(command, "staleness_budget", SerializeStalenessBudget(state.StalenessBudget));
AddParameter(command, "drift_baseline_seconds", state.DriftBaselineSeconds);
AddJsonbParameter(command, "content_budgets", SerializeContentBudgets(state.ContentBudgets));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static AirGapState Map(NpgsqlDataReader reader)
{
var id = reader.GetString(0);
var tenantId = reader.GetString(1);
var sealed_ = reader.GetBoolean(2);
var policyHash = reader.IsDBNull(3) ? null : reader.GetString(3);
var timeAnchorJson = reader.GetFieldValue<string>(4);
var lastTransitionAt = reader.GetFieldValue<DateTimeOffset>(5);
var stalenessBudgetJson = reader.GetFieldValue<string>(6);
var driftBaselineSeconds = reader.GetInt64(7);
var contentBudgetsJson = reader.IsDBNull(8) ? null : reader.GetFieldValue<string>(8);
var timeAnchor = DeserializeTimeAnchor(timeAnchorJson);
var stalenessBudget = DeserializeStalenessBudget(stalenessBudgetJson);
var contentBudgets = DeserializeContentBudgets(contentBudgetsJson);
return new AirGapState
{
Id = id,
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
TimeAnchor = timeAnchor,
LastTransitionAt = lastTransitionAt,
StalenessBudget = stalenessBudget,
DriftBaselineSeconds = driftBaselineSeconds,
ContentBudgets = contentBudgets
};
}
#region Serialization
private static string SerializeTimeAnchor(TimeAnchor anchor)
{
var obj = new
{
anchorTime = anchor.AnchorTime,
source = anchor.Source,
format = anchor.Format,
signatureFingerprint = anchor.SignatureFingerprint,
tokenDigest = anchor.TokenDigest
};
return JsonSerializer.Serialize(obj);
}
private static TimeAnchor DeserializeTimeAnchor(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var anchorTime = root.GetProperty("anchorTime").GetDateTimeOffset();
var source = root.GetProperty("source").GetString() ?? "unknown";
var format = root.GetProperty("format").GetString() ?? "unknown";
var signatureFingerprint = root.TryGetProperty("signatureFingerprint", out var sf) && sf.ValueKind == JsonValueKind.String
? sf.GetString() ?? ""
: "";
var tokenDigest = root.TryGetProperty("tokenDigest", out var td) && td.ValueKind == JsonValueKind.String
? td.GetString() ?? ""
: "";
return new TimeAnchor(anchorTime, source, format, signatureFingerprint, tokenDigest);
}
catch
{
return TimeAnchor.Unknown;
}
}
private static string SerializeStalenessBudget(StalenessBudget budget)
{
var obj = new
{
warningSeconds = budget.WarningSeconds,
breachSeconds = budget.BreachSeconds
};
return JsonSerializer.Serialize(obj);
}
private static StalenessBudget DeserializeStalenessBudget(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var warningSeconds = root.GetProperty("warningSeconds").GetInt64();
var breachSeconds = root.GetProperty("breachSeconds").GetInt64();
return new StalenessBudget(warningSeconds, breachSeconds);
}
catch
{
return StalenessBudget.Default;
}
}
private static string SerializeContentBudgets(IReadOnlyDictionary<string, StalenessBudget> budgets)
{
if (budgets.Count == 0)
{
return "{}";
}
var dict = budgets.ToDictionary(
kv => kv.Key,
kv => new { warningSeconds = kv.Value.WarningSeconds, breachSeconds = kv.Value.BreachSeconds });
return JsonSerializer.Serialize(dict);
}
private static IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}
try
{
using var doc = JsonDocument.Parse(json);
var result = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
foreach (var property in doc.RootElement.EnumerateObject())
{
var warningSeconds = property.Value.GetProperty("warningSeconds").GetInt64();
var breachSeconds = property.Value.GetProperty("breachSeconds").GetInt64();
result[property.Name] = new StalenessBudget(warningSeconds, breachSeconds);
}
return result;
}
catch
{
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}
}
#endregion
private async ValueTask EnsureTableAsync(CancellationToken cancellationToken)
{
if (_initialized)
{
return;
}
await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_initialized)
{
return;
}
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
CREATE SCHEMA IF NOT EXISTS airgap;
CREATE TABLE IF NOT EXISTS airgap.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 '{}',
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
staleness_budget JSONB NOT NULL DEFAULT '{"warningSeconds":3600,"breachSeconds":7200}',
drift_baseline_seconds BIGINT NOT NULL DEFAULT 0,
content_budgets JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_airgap_state_sealed ON airgap.state(sealed) WHERE sealed = TRUE;
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_initialized = true;
}
finally
{
_initLock.Release();
}
}
}

View File

@@ -0,0 +1,296 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for AirGap bundle version activation tracking.
/// </summary>
public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource>, IBundleVersionStore
{
private volatile bool _initialized;
private readonly SemaphoreSlim _initLock = new(1, 1);
public PostgresBundleVersionStore(AirGapDataSource dataSource, ILogger<PostgresBundleVersionStore> logger)
: base(dataSource, logger)
{
}
public async Task<BundleVersionRecord?> GetCurrentAsync(
string tenantId,
string bundleType,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
await EnsureTablesAsync(ct).ConfigureAwait(false);
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string 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 airgap.bundle_versions
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "bundle_type", bundleTypeKey);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
}
public async Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(record);
await EnsureTablesAsync(ct).ConfigureAwait(false);
var tenantKey = NormalizeKey(record.TenantId);
var bundleTypeKey = NormalizeKey(record.BundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", ct).ConfigureAwait(false);
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
const string closeHistorySql = """
UPDATE airgap.bundle_version_history
SET deactivated_at = @activated_at
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
""";
await using (var closeCmd = CreateCommand(closeHistorySql, connection))
{
closeCmd.Transaction = tx;
AddParameter(closeCmd, "tenant_id", tenantKey);
AddParameter(closeCmd, "bundle_type", bundleTypeKey);
AddParameter(closeCmd, "activated_at", record.ActivatedAt);
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string historySql = """
INSERT INTO airgap.bundle_version_history (
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
)
VALUES (
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
@bundle_created_at, @bundle_digest, @activated_at, NULL, @was_force_activated, @force_activate_reason
);
""";
await using (var historyCmd = CreateCommand(historySql, connection))
{
historyCmd.Transaction = tx;
AddParameter(historyCmd, "tenant_id", tenantKey);
AddParameter(historyCmd, "bundle_type", bundleTypeKey);
AddParameter(historyCmd, "version_string", record.VersionString);
AddParameter(historyCmd, "major", record.Major);
AddParameter(historyCmd, "minor", record.Minor);
AddParameter(historyCmd, "patch", record.Patch);
AddParameter(historyCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
AddParameter(historyCmd, "bundle_created_at", record.BundleCreatedAt);
AddParameter(historyCmd, "bundle_digest", record.BundleDigest);
AddParameter(historyCmd, "activated_at", record.ActivatedAt);
AddParameter(historyCmd, "was_force_activated", record.WasForceActivated);
AddParameter(historyCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string upsertSql = """
INSERT INTO airgap.bundle_versions (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
)
VALUES (
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
@bundle_created_at, @bundle_digest, @activated_at, @was_force_activated, @force_activate_reason
)
ON CONFLICT (tenant_id, bundle_type) DO UPDATE SET
version_string = EXCLUDED.version_string,
major = EXCLUDED.major,
minor = EXCLUDED.minor,
patch = EXCLUDED.patch,
prerelease = EXCLUDED.prerelease,
bundle_created_at = EXCLUDED.bundle_created_at,
bundle_digest = EXCLUDED.bundle_digest,
activated_at = EXCLUDED.activated_at,
was_force_activated = EXCLUDED.was_force_activated,
force_activate_reason = EXCLUDED.force_activate_reason,
updated_at = NOW();
""";
await using (var upsertCmd = CreateCommand(upsertSql, connection))
{
upsertCmd.Transaction = tx;
AddParameter(upsertCmd, "tenant_id", tenantKey);
AddParameter(upsertCmd, "bundle_type", bundleTypeKey);
AddParameter(upsertCmd, "version_string", record.VersionString);
AddParameter(upsertCmd, "major", record.Major);
AddParameter(upsertCmd, "minor", record.Minor);
AddParameter(upsertCmd, "patch", record.Patch);
AddParameter(upsertCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
AddParameter(upsertCmd, "bundle_created_at", record.BundleCreatedAt);
AddParameter(upsertCmd, "bundle_digest", record.BundleDigest);
AddParameter(upsertCmd, "activated_at", record.ActivatedAt);
AddParameter(upsertCmd, "was_force_activated", record.WasForceActivated);
AddParameter(upsertCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
await upsertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
await tx.CommitAsync(ct).ConfigureAwait(false);
}
public async Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(
string tenantId,
string bundleType,
int limit = 10,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
if (limit <= 0)
{
return Array.Empty<BundleVersionRecord>();
}
await EnsureTablesAsync(ct).ConfigureAwait(false);
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string 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 airgap.bundle_version_history
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
ORDER BY activated_at DESC
LIMIT @limit;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "bundle_type", bundleTypeKey);
AddParameter(command, "limit", limit);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
var results = new List<BundleVersionRecord>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(Map(reader));
}
return results;
}
private static BundleVersionRecord Map(NpgsqlDataReader reader)
{
var tenantId = reader.GetString(0);
var bundleType = reader.GetString(1);
var versionString = reader.GetString(2);
var major = reader.GetInt32(3);
var minor = reader.GetInt32(4);
var patch = reader.GetInt32(5);
var prerelease = reader.IsDBNull(6) ? null : reader.GetString(6);
var bundleCreatedAt = reader.GetFieldValue<DateTimeOffset>(7);
var bundleDigest = reader.GetString(8);
var activatedAt = reader.GetFieldValue<DateTimeOffset>(9);
var wasForceActivated = reader.GetBoolean(10);
var forceActivateReason = reader.IsDBNull(11) ? null : reader.GetString(11);
return new BundleVersionRecord(
TenantId: tenantId,
BundleType: bundleType,
VersionString: versionString,
Major: major,
Minor: minor,
Patch: patch,
Prerelease: prerelease,
BundleCreatedAt: bundleCreatedAt,
BundleDigest: bundleDigest,
ActivatedAt: activatedAt,
WasForceActivated: wasForceActivated,
ForceActivateReason: forceActivateReason);
}
private async ValueTask EnsureTablesAsync(CancellationToken ct)
{
if (_initialized)
{
return;
}
await _initLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_initialized)
{
return;
}
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
const string sql = """
CREATE SCHEMA IF NOT EXISTS airgap;
CREATE TABLE IF NOT EXISTS airgap.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)
);
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_versions_tenant
ON airgap.bundle_versions(tenant_id);
CREATE TABLE IF NOT EXISTS airgap.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 airgap.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
{
_initLock.Release();
}
}
private static string NormalizeKey(string value) => value.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.AirGap.Persistence</RootNamespace>
<AssemblyName>StellaOps.AirGap.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps AirGap module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="..\..\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>