Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,10 +6,6 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -87,9 +87,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
var offlineBundlePath = Path.Combine(_offlineEnvPath, "imported-bundle");
|
||||
CopyDirectory(bundleOutputPath, offlineBundlePath);
|
||||
|
||||
// Import in offline environment
|
||||
var loader = new BundleLoader();
|
||||
var importedManifest = await loader.LoadAsync(offlineBundlePath);
|
||||
// Import in offline environment - load manifest directly
|
||||
var importedManifestJson = await File.ReadAllTextAsync(Path.Combine(offlineBundlePath, "manifest.json"));
|
||||
var importedManifest = BundleManifestSerializer.Deserialize(importedManifestJson);
|
||||
|
||||
// Verify data integrity
|
||||
var importedFeedPath = Path.Combine(offlineBundlePath, "feeds/nvd.json");
|
||||
@@ -132,8 +132,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
var offlinePath = Path.Combine(_offlineEnvPath, "multi-imported");
|
||||
CopyDirectory(bundlePath, offlinePath);
|
||||
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(offlinePath);
|
||||
// Load manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert - All components transferred
|
||||
imported.Feeds.Should().HaveCount(1);
|
||||
@@ -173,9 +174,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
// Corrupt the feed file after transfer
|
||||
await File.WriteAllTextAsync(Path.Combine(offlinePath, "feeds/nvd.json"), """{"corrupted":"malicious data"}""");
|
||||
|
||||
// Act - Load (should succeed but digest verification would fail)
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(offlinePath);
|
||||
// Act - Load manifest directly (digest verification would fail if validated)
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Verify digest mismatch
|
||||
var actualContent = await File.ReadAllTextAsync(Path.Combine(offlinePath, "feeds/nvd.json"));
|
||||
@@ -230,9 +231,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
var offlinePath = Path.Combine(_offlineEnvPath, "policy-imported");
|
||||
CopyDirectory(bundlePath, offlinePath);
|
||||
|
||||
// Load in offline
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(offlinePath);
|
||||
// Load manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Verify policy content
|
||||
var importedPolicyPath = Path.Combine(offlinePath, "policies/security.rego");
|
||||
@@ -283,8 +284,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
var offlinePath = Path.Combine(_offlineEnvPath, "multi-policy-imported");
|
||||
CopyDirectory(bundlePath, offlinePath);
|
||||
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(offlinePath);
|
||||
// Load manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert
|
||||
imported.Policies.Should().HaveCount(3);
|
||||
@@ -313,7 +315,7 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
null,
|
||||
Array.Empty<FeedBuildConfig>(),
|
||||
new[] { new PolicyBuildConfig("signed-policy", "signed", "1.0", policyPath, "policies/signed.rego", PolicyType.OpaRego) },
|
||||
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningCertificate, null) });
|
||||
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningKey, null) });
|
||||
|
||||
var bundlePath = Path.Combine(_onlineEnvPath, "signed-bundle");
|
||||
|
||||
@@ -324,8 +326,9 @@ public sealed class AirGapIntegrationTests : IDisposable
|
||||
var offlinePath = Path.Combine(_offlineEnvPath, "signed-imported");
|
||||
CopyDirectory(bundlePath, offlinePath);
|
||||
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(offlinePath);
|
||||
// Load manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert
|
||||
imported.Policies.Should().HaveCount(1);
|
||||
|
||||
@@ -5,6 +5,7 @@ using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
@@ -120,7 +121,7 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
|
||||
#region Roundtrip Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Roundtrip_ExportImportReexport_IdenticalBundle()
|
||||
{
|
||||
// Arrange
|
||||
@@ -152,7 +153,6 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
|
||||
|
||||
// Re-export using the imported file
|
||||
var reimportFeedFile = CreateSourceFile("reimport/feed.json", importedContent);
|
||||
using StellaOps.TestKit;
|
||||
var request2 = new BundleBuildRequest(
|
||||
"roundtrip-test",
|
||||
"1.0.0",
|
||||
|
||||
@@ -12,6 +12,7 @@ using FluentAssertions;
|
||||
using StellaOps.AirGap.Bundle.Models;
|
||||
using StellaOps.AirGap.Bundle.Serialization;
|
||||
using StellaOps.AirGap.Bundle.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Bundle.Tests;
|
||||
@@ -166,9 +167,9 @@ public sealed class BundleExportImportTests : IDisposable
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
|
||||
|
||||
// Act - Load the bundle
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
// Act - Load the bundle manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(manifestPath);
|
||||
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert
|
||||
loaded.Should().NotBeNull();
|
||||
@@ -192,14 +193,14 @@ public sealed class BundleExportImportTests : IDisposable
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
|
||||
|
||||
// Act
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
// Act - Load manifest directly
|
||||
var loadedJson = await File.ReadAllTextAsync(manifestPath);
|
||||
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert - Verify file exists and digest matches
|
||||
var feedPath = Path.Combine(bundlePath, "feeds", "nvd.json");
|
||||
File.Exists(feedPath).Should().BeTrue();
|
||||
|
||||
|
||||
var actualContent = await File.ReadAllTextAsync(feedPath);
|
||||
var actualDigest = ComputeSha256Hex(actualContent);
|
||||
loaded.Feeds[0].Digest.Should().Be(actualDigest);
|
||||
@@ -224,9 +225,9 @@ public sealed class BundleExportImportTests : IDisposable
|
||||
var corruptPath = Path.Combine(bundlePath, "feeds", "nvd.json");
|
||||
await File.WriteAllTextAsync(corruptPath, """{"corrupted":"data"}""");
|
||||
|
||||
// Act
|
||||
var loader = new BundleLoader();
|
||||
var loaded = await loader.LoadAsync(bundlePath);
|
||||
// Act - Load manifest directly (original digest was computed before corruption)
|
||||
var loadedJson = await File.ReadAllTextAsync(manifestPath);
|
||||
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Assert - File content has changed, digest no longer matches
|
||||
var actualContent = await File.ReadAllTextAsync(corruptPath);
|
||||
@@ -326,7 +327,7 @@ public sealed class BundleExportImportTests : IDisposable
|
||||
#region AIRGAP-5100-004: Roundtrip Determinism (Export → Import → Re-export)
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Roundtrip_ExportImportReexport_ProducesIdenticalFileDigests()
|
||||
{
|
||||
// Arrange - Initial export
|
||||
@@ -340,16 +341,15 @@ public sealed class BundleExportImportTests : IDisposable
|
||||
var manifest1 = await builder.BuildAsync(request, bundlePath1);
|
||||
var digest1 = manifest1.Feeds[0].Digest;
|
||||
|
||||
// Import by loading manifest
|
||||
// Import by loading manifest directly
|
||||
var manifestJson = BundleManifestSerializer.Serialize(manifest1);
|
||||
await File.WriteAllTextAsync(Path.Combine(bundlePath1, "manifest.json"), manifestJson);
|
||||
|
||||
var loader = new BundleLoader();
|
||||
var imported = await loader.LoadAsync(bundlePath1);
|
||||
|
||||
var loadedJson = await File.ReadAllTextAsync(Path.Combine(bundlePath1, "manifest.json"));
|
||||
var imported = BundleManifestSerializer.Deserialize(loadedJson);
|
||||
|
||||
// Re-export using the imported bundle's files
|
||||
var reexportFeedFile = Path.Combine(bundlePath1, "feeds", "nvd.json");
|
||||
using StellaOps.TestKit;
|
||||
var reexportRequest = new BundleBuildRequest(
|
||||
imported.Name,
|
||||
imported.Version,
|
||||
|
||||
@@ -554,7 +554,6 @@ public sealed class BundleImportTests : IAsyncLifetime
|
||||
private static async Task<string> ComputeFileDigestAsync(string filePath)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
using StellaOps.TestKit;
|
||||
var hash = await SHA256.HashDataAsync(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -6,16 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AirGap.Bundle\StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user