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,64 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.Infrastructure.EfCore.Context;
/// <summary>
/// Base DbContext for StellaOps modules with schema isolation.
/// </summary>
/// <remarks>
/// Derived contexts should:
/// 1. Override <see cref="SchemaName"/> to specify the module's PostgreSQL schema
/// 2. Define DbSet properties for module entities
/// 3. Configure compiled model in OnConfiguring if using compiled models
/// </remarks>
public abstract class StellaOpsDbContextBase : DbContext
{
/// <summary>
/// PostgreSQL schema name for this module's tables.
/// </summary>
protected abstract string SchemaName { get; }
/// <summary>
/// Creates a new DbContext with the specified options.
/// </summary>
protected StellaOpsDbContextBase(DbContextOptions options) : base(options)
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Set the default schema for all entities
modelBuilder.HasDefaultSchema(SchemaName);
base.OnModelCreating(modelBuilder);
}
/// <summary>
/// Executes a raw SQL query and returns the result.
/// Use for complex queries that don't map well to EF Core (CTEs, window functions, etc.).
/// </summary>
/// <typeparam name="T">Result type.</typeparam>
/// <param name="sql">Parameterized SQL query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Query results.</returns>
public async Task<List<T>> RawSqlQueryAsync<T>(
FormattableString sql,
CancellationToken cancellationToken = default) where T : class
{
return await Database.SqlQuery<T>(sql).ToListAsync(cancellationToken);
}
/// <summary>
/// Executes a raw SQL command (INSERT, UPDATE, DELETE).
/// Use for bulk operations or complex mutations.
/// </summary>
/// <param name="sql">Parameterized SQL command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of rows affected.</returns>
public async Task<int> RawSqlExecuteAsync(
FormattableString sql,
CancellationToken cancellationToken = default)
{
return await Database.ExecuteSqlInterpolatedAsync(sql, cancellationToken);
}
}

View File

@@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.EfCore.Interceptors;
using StellaOps.Infrastructure.EfCore.Tenancy;
namespace StellaOps.Infrastructure.EfCore.Extensions;
/// <summary>
/// Extension methods for registering StellaOps EF Core DbContexts with tenant isolation.
/// </summary>
public static class DbContextServiceExtensions
{
/// <summary>
/// Registers a StellaOps DbContext with tenant connection interceptor.
/// </summary>
/// <typeparam name="TContext">DbContext type to register.</typeparam>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
/// <param name="configureOptions">Optional additional configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddStellaOpsDbContext<TContext>(
this IServiceCollection services,
string connectionString,
string schemaName,
Action<DbContextOptionsBuilder>? configureOptions = null)
where TContext : DbContext
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
services.AddDbContext<TContext>((sp, options) =>
{
options.UseNpgsql(connectionString, npgsql =>
{
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
});
// Add tenant connection interceptor if tenant accessor is registered
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
if (tenantAccessor != null)
{
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
}
// Enable detailed error messages in development
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging(false); // Keep false for security
configureOptions?.Invoke(options);
});
return services;
}
/// <summary>
/// Registers a StellaOps DbContext with compiled model and tenant connection interceptor.
/// </summary>
/// <typeparam name="TContext">DbContext type to register.</typeparam>
/// <typeparam name="TCompiledModel">Compiled model type (implements IModel).</typeparam>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
/// <param name="compiledModelInstance">Instance of the compiled model.</param>
/// <param name="configureOptions">Optional additional configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddStellaOpsDbContextWithCompiledModel<TContext, TCompiledModel>(
this IServiceCollection services,
string connectionString,
string schemaName,
TCompiledModel compiledModelInstance,
Action<DbContextOptionsBuilder>? configureOptions = null)
where TContext : DbContext
where TCompiledModel : class, IModel
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
ArgumentNullException.ThrowIfNull(compiledModelInstance);
services.AddDbContext<TContext>((sp, options) =>
{
options.UseNpgsql(connectionString, npgsql =>
{
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
});
// Use compiled model for faster startup
options.UseModel(compiledModelInstance);
// Add tenant connection interceptor if tenant accessor is registered
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
if (tenantAccessor != null)
{
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
}
// Enable detailed error messages in development
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging(false);
configureOptions?.Invoke(options);
});
return services;
}
/// <summary>
/// Registers a DbContext factory for creating DbContext instances.
/// Useful for background services and worker scenarios.
/// </summary>
/// <typeparam name="TContext">DbContext type to register.</typeparam>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string.</param>
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
/// <param name="configureOptions">Optional additional configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddStellaOpsDbContextFactory<TContext>(
this IServiceCollection services,
string connectionString,
string schemaName,
Action<DbContextOptionsBuilder>? configureOptions = null)
where TContext : DbContext
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
services.AddDbContextFactory<TContext>((sp, options) =>
{
options.UseNpgsql(connectionString, npgsql =>
{
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName);
});
// Add tenant connection interceptor if tenant accessor is registered
var tenantAccessor = sp.GetService<ITenantContextAccessor>();
if (tenantAccessor != null)
{
var logger = sp.GetService<ILogger<TenantConnectionInterceptor>>();
options.AddInterceptors(new TenantConnectionInterceptor(tenantAccessor, schemaName, logger));
}
options.EnableDetailedErrors();
options.EnableSensitiveDataLogging(false);
configureOptions?.Invoke(options);
});
return services;
}
}

View File

@@ -0,0 +1,120 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.EfCore.Tenancy;
namespace StellaOps.Infrastructure.EfCore.Interceptors;
/// <summary>
/// Sets tenant context and session configuration on each PostgreSQL connection.
/// Mirrors DataSourceBase.ConfigureSessionAsync() behavior for EF Core contexts.
/// </summary>
/// <remarks>
/// Session settings applied:
/// - TIME ZONE 'UTC' for deterministic timestamps
/// - app.current_tenant / app.tenant_id for RLS
/// - search_path to module schema
/// </remarks>
public sealed class TenantConnectionInterceptor : DbConnectionInterceptor
{
private readonly ITenantContextAccessor _tenantAccessor;
private readonly string _schemaName;
private readonly ILogger<TenantConnectionInterceptor>? _logger;
/// <summary>
/// Creates a new tenant connection interceptor.
/// </summary>
/// <param name="tenantAccessor">Provider for current tenant context.</param>
/// <param name="schemaName">PostgreSQL schema name for search_path.</param>
/// <param name="logger">Optional logger for diagnostics.</param>
public TenantConnectionInterceptor(
ITenantContextAccessor tenantAccessor,
string schemaName,
ILogger<TenantConnectionInterceptor>? logger = null)
{
ArgumentNullException.ThrowIfNull(tenantAccessor);
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
_tenantAccessor = tenantAccessor;
_schemaName = schemaName;
_logger = logger;
}
/// <inheritdoc />
public override async Task ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken cancellationToken = default)
{
if (connection is not NpgsqlConnection npgsqlConnection)
{
return;
}
var tenantId = _tenantAccessor.TenantId ?? "_system";
try
{
await ConfigureSessionAsync(npgsqlConnection, tenantId, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger?.LogError(ex,
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
tenantId, _schemaName);
throw;
}
}
/// <inheritdoc />
public override void ConnectionOpened(
DbConnection connection,
ConnectionEndEventData eventData)
{
if (connection is not NpgsqlConnection npgsqlConnection)
{
return;
}
var tenantId = _tenantAccessor.TenantId ?? "_system";
try
{
ConfigureSessionAsync(npgsqlConnection, tenantId, CancellationToken.None)
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
_logger?.LogError(ex,
"Failed to configure PostgreSQL session for tenant {TenantId} in schema {Schema}",
tenantId, _schemaName);
throw;
}
}
private async Task ConfigureSessionAsync(
NpgsqlConnection connection,
string tenantId,
CancellationToken cancellationToken)
{
// Combine all session configuration into a single command for efficiency
var sql = $"""
SET TIME ZONE 'UTC';
SELECT set_config('app.current_tenant', $1, false),
set_config('app.tenant_id', $1, false);
SET search_path TO {_schemaName}, public;
""";
await using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue(tenantId);
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger?.LogDebug(
"Configured PostgreSQL session: tenant={TenantId}, schema={Schema}",
tenantId, _schemaName);
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Infrastructure.EfCore</RootNamespace>
<AssemblyName>StellaOps.Infrastructure.EfCore</AssemblyName>
<Description>Shared EF Core infrastructure for StellaOps modules with tenant isolation support</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,56 @@
namespace StellaOps.Infrastructure.EfCore.Tenancy;
/// <summary>
/// Tenant context accessor using AsyncLocal for tenant propagation across async calls.
/// Use this for message handlers, background jobs, and other async scenarios.
/// </summary>
public sealed class AsyncLocalTenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<string?> _tenantId = new();
/// <inheritdoc />
public string? TenantId => _tenantId.Value;
/// <summary>
/// Sets the current tenant ID for the async scope.
/// </summary>
/// <param name="tenantId">Tenant ID to set.</param>
public static void SetTenantId(string? tenantId)
{
_tenantId.Value = tenantId;
}
/// <summary>
/// Clears the current tenant ID.
/// </summary>
public static void ClearTenantId()
{
_tenantId.Value = null;
}
/// <summary>
/// Creates a scope that sets the tenant ID and clears it on dispose.
/// </summary>
/// <param name="tenantId">Tenant ID to set.</param>
/// <returns>Disposable scope.</returns>
public static IDisposable CreateScope(string tenantId)
{
return new TenantScope(tenantId);
}
private sealed class TenantScope : IDisposable
{
private readonly string? _previousTenantId;
public TenantScope(string tenantId)
{
_previousTenantId = _tenantId.Value;
_tenantId.Value = tenantId;
}
public void Dispose()
{
_tenantId.Value = _previousTenantId;
}
}
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Infrastructure.EfCore.Tenancy;
/// <summary>
/// Provides access to the current tenant context.
/// Implement this interface for your specific authentication mechanism (headers, claims, etc.).
/// </summary>
public interface ITenantContextAccessor
{
/// <summary>
/// Gets the current tenant ID, or null if not available.
/// Returns "_system" for system/admin operations.
/// </summary>
string? TenantId { get; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Infrastructure.EfCore.Tenancy;
/// <summary>
/// Tenant context accessor that always returns "_system".
/// Use for background services, migrations, and admin operations.
/// </summary>
public sealed class SystemTenantContextAccessor : ITenantContextAccessor
{
/// <summary>
/// Singleton instance.
/// </summary>
public static readonly SystemTenantContextAccessor Instance = new();
/// <inheritdoc />
public string? TenantId => "_system";
}