release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering plugin registry services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the plugin registry to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<PluginRegistryOptions>()
|
||||
.Bind(configuration.GetSection(PluginRegistryOptions.SectionName));
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Configure NpgsqlDataSource
|
||||
services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PluginRegistryOptions>>().Value;
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(options.ConnectionString);
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin registry with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<PluginRegistryOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Configure NpgsqlDataSource
|
||||
services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PluginRegistryOptions>>().Value;
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(options.ConnectionString);
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin registry with an existing data source.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="dataSource">The NpgsqlDataSource to use.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
// Add default options
|
||||
services.AddOptions<PluginRegistryOptions>();
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Use provided data source
|
||||
services.AddSingleton(dataSource);
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
143
src/Plugin/StellaOps.Plugin.Registry/IPluginRegistry.cs
Normal file
143
src/Plugin/StellaOps.Plugin.Registry/IPluginRegistry.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Database-backed plugin registry for persistent plugin management.
|
||||
/// </summary>
|
||||
public interface IPluginRegistry
|
||||
{
|
||||
// ========== Plugin Management ==========
|
||||
|
||||
/// <summary>
|
||||
/// Register a loaded plugin in the database.
|
||||
/// </summary>
|
||||
Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update plugin status.
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update plugin health status.
|
||||
/// </summary>
|
||||
Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin.
|
||||
/// </summary>
|
||||
Task UnregisterAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugin by ID.
|
||||
/// </summary>
|
||||
Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get all registered plugins.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct);
|
||||
|
||||
// ========== Capability Queries ==========
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins with a specific capability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins providing a specific capability type/id.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(string capabilityType, string? capabilityId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Register plugin capabilities.
|
||||
/// </summary>
|
||||
Task RegisterCapabilitiesAsync(Guid pluginDbId, IEnumerable<PluginCapabilityRecord> capabilities, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get capabilities for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
// ========== Instance Management ==========
|
||||
|
||||
/// <summary>
|
||||
/// Create a tenant-specific plugin instance.
|
||||
/// </summary>
|
||||
Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugin instance.
|
||||
/// </summary>
|
||||
Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get instances for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get instances for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update instance configuration.
|
||||
/// </summary>
|
||||
Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable instance.
|
||||
/// </summary>
|
||||
Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Delete instance.
|
||||
/// </summary>
|
||||
Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct);
|
||||
|
||||
// ========== Health History ==========
|
||||
|
||||
/// <summary>
|
||||
/// Record health check result.
|
||||
/// </summary>
|
||||
Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get health history for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a plugin instance.
|
||||
/// </summary>
|
||||
/// <param name="PluginId">Plugin identifier.</param>
|
||||
/// <param name="TenantId">Optional tenant ID for multi-tenant isolation.</param>
|
||||
/// <param name="InstanceName">Optional instance name.</param>
|
||||
/// <param name="Config">Instance configuration as JSON.</param>
|
||||
/// <param name="SecretsPath">Path to secrets location.</param>
|
||||
/// <param name="ResourceLimits">Optional resource limits.</param>
|
||||
public sealed record CreatePluginInstanceRequest(
|
||||
string PluginId,
|
||||
Guid? TenantId,
|
||||
string? InstanceName,
|
||||
JsonDocument Config,
|
||||
string? SecretsPath = null,
|
||||
JsonDocument? ResourceLimits = null);
|
||||
425
src/Plugin/StellaOps.Plugin.Registry/InMemoryPluginRegistry.cs
Normal file
425
src/Plugin/StellaOps.Plugin.Registry/InMemoryPluginRegistry.cs
Normal file
@@ -0,0 +1,425 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the plugin registry for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPluginRegistry : IPluginRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, PluginRecord> _plugins = new();
|
||||
private readonly ConcurrentDictionary<Guid, PluginCapabilityRecord> _capabilities = new();
|
||||
private readonly ConcurrentDictionary<Guid, PluginInstanceRecord> _instances = new();
|
||||
private readonly ConcurrentDictionary<Guid, List<PluginHealthRecord>> _healthHistory = new();
|
||||
private readonly ILogger<InMemoryPluginRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new in-memory plugin registry instance.
|
||||
/// </summary>
|
||||
public InMemoryPluginRegistry(
|
||||
ILogger<InMemoryPluginRegistry> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
// Get current health status from last check result if available
|
||||
var healthStatus = plugin.LastHealthCheckResult?.Status ?? HealthStatus.Unknown;
|
||||
|
||||
var record = new PluginRecord
|
||||
{
|
||||
Id = id,
|
||||
PluginId = plugin.Info.Id,
|
||||
Name = plugin.Info.Name,
|
||||
Version = plugin.Info.Version,
|
||||
Vendor = plugin.Info.Vendor,
|
||||
Description = plugin.Info.Description,
|
||||
LicenseId = plugin.Info.LicenseId,
|
||||
TrustLevel = plugin.TrustLevel,
|
||||
Capabilities = plugin.Capabilities,
|
||||
Status = plugin.State,
|
||||
HealthStatus = healthStatus,
|
||||
Source = "installed",
|
||||
AssemblyPath = plugin.Manifest?.AssemblyPath,
|
||||
EntryPoint = plugin.Manifest?.EntryPoint,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
LoadedAt = plugin.LoadedAt
|
||||
};
|
||||
|
||||
_plugins[id] = record;
|
||||
_logger.LogDebug("Registered plugin {PluginId} with ID {Id}", plugin.Info.Id, id);
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
var updated = new PluginRecord
|
||||
{
|
||||
Id = record.Id,
|
||||
PluginId = record.PluginId,
|
||||
Name = record.Name,
|
||||
Version = record.Version,
|
||||
Vendor = record.Vendor,
|
||||
Description = record.Description,
|
||||
LicenseId = record.LicenseId,
|
||||
TrustLevel = record.TrustLevel,
|
||||
Capabilities = record.Capabilities,
|
||||
Status = status,
|
||||
StatusMessage = message,
|
||||
HealthStatus = record.HealthStatus,
|
||||
LastHealthCheck = record.LastHealthCheck,
|
||||
HealthCheckFailures = record.HealthCheckFailures,
|
||||
Source = record.Source,
|
||||
AssemblyPath = record.AssemblyPath,
|
||||
EntryPoint = record.EntryPoint,
|
||||
Manifest = record.Manifest,
|
||||
CreatedAt = record.CreatedAt,
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
LoadedAt = record.LoadedAt
|
||||
};
|
||||
_plugins[record.Id] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = new PluginRecord
|
||||
{
|
||||
Id = record.Id,
|
||||
PluginId = record.PluginId,
|
||||
Name = record.Name,
|
||||
Version = record.Version,
|
||||
Vendor = record.Vendor,
|
||||
Description = record.Description,
|
||||
LicenseId = record.LicenseId,
|
||||
TrustLevel = record.TrustLevel,
|
||||
Capabilities = record.Capabilities,
|
||||
Status = record.Status,
|
||||
StatusMessage = record.StatusMessage,
|
||||
HealthStatus = status,
|
||||
LastHealthCheck = now,
|
||||
HealthCheckFailures = status == HealthStatus.Healthy ? 0 : record.HealthCheckFailures + 1,
|
||||
Source = record.Source,
|
||||
AssemblyPath = record.AssemblyPath,
|
||||
EntryPoint = record.EntryPoint,
|
||||
Manifest = record.Manifest,
|
||||
CreatedAt = record.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
LoadedAt = record.LoadedAt
|
||||
};
|
||||
_plugins[record.Id] = updated;
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
RecordHealthCheckAsync(pluginId, result, ct).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnregisterAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
_plugins.TryRemove(record.Id, out _);
|
||||
|
||||
// Remove capabilities
|
||||
var capsToRemove = _capabilities.Values.Where(c => c.PluginId == record.Id).ToList();
|
||||
foreach (var cap in capsToRemove)
|
||||
{
|
||||
_capabilities.TryRemove(cap.Id, out _);
|
||||
}
|
||||
|
||||
// Remove instances
|
||||
var instancesToRemove = _instances.Values.Where(i => i.PluginId == record.Id).ToList();
|
||||
foreach (var instance in instancesToRemove)
|
||||
{
|
||||
_instances.TryRemove(instance.Id, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values.OrderBy(p => p.Name).ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values
|
||||
.Where(p => p.Status == status)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values
|
||||
.Where(p => (p.Capabilities & capability) != 0 && p.Status == PluginLifecycleState.Active)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(string capabilityType, string? capabilityId = null, CancellationToken ct = default)
|
||||
{
|
||||
var matchingCaps = _capabilities.Values
|
||||
.Where(c => c.CapabilityType == capabilityType && c.IsEnabled)
|
||||
.Where(c => capabilityId == null || c.CapabilityId == capabilityId);
|
||||
|
||||
var pluginIds = matchingCaps.Select(c => c.PluginId).ToHashSet();
|
||||
|
||||
var result = _plugins.Values
|
||||
.Where(p => pluginIds.Contains(p.Id) && p.Status == PluginLifecycleState.Active)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RegisterCapabilitiesAsync(Guid pluginDbId, IEnumerable<PluginCapabilityRecord> capabilities, CancellationToken ct)
|
||||
{
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
var record = cap with { PluginId = pluginDbId };
|
||||
_capabilities[cap.Id] = record;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginCapabilityRecord>>([]);
|
||||
}
|
||||
|
||||
var result = _capabilities.Values
|
||||
.Where(c => c.PluginId == plugin.Id)
|
||||
.OrderBy(c => c.CapabilityType)
|
||||
.ThenBy(c => c.CapabilityId)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginCapabilityRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == request.PluginId)
|
||||
?? throw new InvalidOperationException($"Plugin {request.PluginId} not found");
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
var record = new PluginInstanceRecord
|
||||
{
|
||||
Id = id,
|
||||
PluginId = plugin.Id,
|
||||
PluginStringId = request.PluginId,
|
||||
TenantId = request.TenantId,
|
||||
InstanceName = request.InstanceName,
|
||||
Config = request.Config,
|
||||
SecretsPath = request.SecretsPath,
|
||||
ResourceLimits = request.ResourceLimits,
|
||||
Enabled = true,
|
||||
Status = "pending",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_instances[id] = record;
|
||||
_logger.LogDebug("Created instance {InstanceId} for plugin {PluginId}", id, request.PluginId);
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
_instances.TryGetValue(instanceId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
var result = _instances.Values
|
||||
.Where(i => i.TenantId == tenantId)
|
||||
.OrderBy(i => i.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>([]);
|
||||
}
|
||||
|
||||
var result = _instances.Values
|
||||
.Where(i => i.PluginId == plugin.Id)
|
||||
.OrderBy(i => i.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct)
|
||||
{
|
||||
if (_instances.TryGetValue(instanceId, out var existing))
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Config = config,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_instances[instanceId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
if (_instances.TryGetValue(instanceId, out var existing))
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Enabled = enabled,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_instances[instanceId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
_instances.TryRemove(instanceId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var healthRecord = new PluginHealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PluginId = plugin.Id,
|
||||
PluginStringId = pluginId,
|
||||
CheckedAt = now,
|
||||
Status = result.Status,
|
||||
ResponseTimeMs = result.Duration.HasValue ? (int)result.Duration.Value.TotalMilliseconds : null,
|
||||
ErrorMessage = result.Message,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_healthHistory.AddOrUpdate(
|
||||
plugin.Id,
|
||||
_ => [healthRecord],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(healthRecord);
|
||||
return list;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null || !_healthHistory.TryGetValue(plugin.Id, out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginHealthRecord>>([]);
|
||||
}
|
||||
|
||||
var result = history
|
||||
.Where(h => h.CheckedAt >= since)
|
||||
.OrderByDescending(h => h.CheckedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginHealthRecord>>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all data from the registry. For testing purposes.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_plugins.Clear();
|
||||
_capabilities.Clear();
|
||||
_instances.Clear();
|
||||
_healthHistory.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Migration: 001_CreatePluginTables
|
||||
-- Creates the core tables for the plugin registry
|
||||
|
||||
-- Ensure schema exists
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
-- Plugin registry table
|
||||
CREATE TABLE IF NOT EXISTS platform.plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
vendor VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
license_id VARCHAR(50),
|
||||
|
||||
-- Trust and security
|
||||
trust_level VARCHAR(50) NOT NULL CHECK (trust_level IN ('builtin', 'trusted', 'untrusted')),
|
||||
signature BYTEA,
|
||||
signing_key_id VARCHAR(255),
|
||||
|
||||
-- Capabilities
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
capability_details JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Source and deployment
|
||||
source VARCHAR(50) NOT NULL CHECK (source IN ('bundled', 'installed', 'discovered')),
|
||||
assembly_path VARCHAR(500),
|
||||
entry_point VARCHAR(255),
|
||||
|
||||
-- Lifecycle
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'discovered' CHECK (status IN (
|
||||
'discovered', 'loading', 'initializing', 'active',
|
||||
'degraded', 'stopping', 'stopped', 'failed', 'unloading'
|
||||
)),
|
||||
status_message TEXT,
|
||||
|
||||
-- Health
|
||||
health_status VARCHAR(50) DEFAULT 'unknown' CHECK (health_status IN (
|
||||
'unknown', 'healthy', 'degraded', 'unhealthy'
|
||||
)),
|
||||
last_health_check TIMESTAMPTZ,
|
||||
health_check_failures INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Metadata
|
||||
manifest JSONB,
|
||||
runtime_info JSONB,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
loaded_at TIMESTAMPTZ,
|
||||
|
||||
UNIQUE(plugin_id, version)
|
||||
);
|
||||
|
||||
-- Plugin capabilities
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_capabilities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
|
||||
capability_type VARCHAR(100) NOT NULL,
|
||||
capability_id VARCHAR(255) NOT NULL,
|
||||
|
||||
config_schema JSONB,
|
||||
input_schema JSONB,
|
||||
output_schema JSONB,
|
||||
|
||||
display_name VARCHAR(255),
|
||||
description TEXT,
|
||||
documentation_url VARCHAR(500),
|
||||
metadata JSONB,
|
||||
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE(plugin_id, capability_type, capability_id)
|
||||
);
|
||||
|
||||
-- Plugin instances (for multi-tenant)
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_instances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
tenant_id UUID,
|
||||
|
||||
instance_name VARCHAR(255),
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
secrets_path VARCHAR(500),
|
||||
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
|
||||
resource_limits JSONB,
|
||||
|
||||
last_used_at TIMESTAMPTZ,
|
||||
invocation_count BIGINT NOT NULL DEFAULT 0,
|
||||
error_count BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE(plugin_id, tenant_id, COALESCE(instance_name, ''))
|
||||
);
|
||||
|
||||
-- Plugin health history
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_health_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status VARCHAR(50) NOT NULL,
|
||||
response_time_ms INT,
|
||||
details JSONB,
|
||||
error_message TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_plugin_id ON platform.plugins(plugin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_status ON platform.plugins(status) WHERE status != 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_trust_level ON platform.plugins(trust_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_capabilities ON platform.plugins USING GIN (capabilities);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_health ON platform.plugins(health_status) WHERE health_status != 'healthy';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_type ON platform.plugin_capabilities(capability_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_lookup ON platform.plugin_capabilities(capability_type, capability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_plugin ON platform.plugin_capabilities(plugin_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_tenant ON platform.plugin_instances(tenant_id) WHERE tenant_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_plugin ON platform.plugin_instances(plugin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_enabled ON platform.plugin_instances(plugin_id, enabled) WHERE enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_health_history_plugin ON platform.plugin_health_history(plugin_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_health_history_checked ON platform.plugin_health_history(checked_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE platform.plugins IS 'Registry of all plugins known to the system';
|
||||
COMMENT ON TABLE platform.plugin_capabilities IS 'Detailed capabilities exposed by each plugin';
|
||||
COMMENT ON TABLE platform.plugin_instances IS 'Tenant-specific plugin instances and configurations';
|
||||
COMMENT ON TABLE platform.plugin_health_history IS 'Historical health check results for plugins';
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin capability record in the database.
|
||||
/// </summary>
|
||||
public sealed record PluginCapabilityRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin.
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability type (e.g., "crypto", "auth", "scm").
|
||||
/// </summary>
|
||||
public required string CapabilityType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability identifier (e.g., specific algorithm or provider).
|
||||
/// </summary>
|
||||
public required string CapabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the capability.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability configuration.
|
||||
/// </summary>
|
||||
public JsonDocument? ConfigSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability input.
|
||||
/// </summary>
|
||||
public JsonDocument? InputSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability output.
|
||||
/// </summary>
|
||||
public JsonDocument? OutputSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to capability documentation.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this capability is enabled.
|
||||
/// </summary>
|
||||
public required bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin health history record in the database.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin (database ID).
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin string identifier (denormalized for queries).
|
||||
/// </summary>
|
||||
public string? PluginStringId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the health check was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health status result.
|
||||
/// </summary>
|
||||
public required HealthStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Response time in milliseconds.
|
||||
/// </summary>
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health check details as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if health check failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin instance record in the database.
|
||||
/// </summary>
|
||||
public sealed record PluginInstanceRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin (database ID).
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin string identifier.
|
||||
/// </summary>
|
||||
public string? PluginStringId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance name for identification.
|
||||
/// </summary>
|
||||
public string? InstanceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance configuration as JSON.
|
||||
/// </summary>
|
||||
public required JsonDocument Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to secrets location.
|
||||
/// </summary>
|
||||
public string? SecretsPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? ResourceLimits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the instance is enabled.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance status.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last time the instance was used.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total invocation count.
|
||||
/// </summary>
|
||||
public long InvocationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total error count.
|
||||
/// </summary>
|
||||
public long ErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record last update timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
122
src/Plugin/StellaOps.Plugin.Registry/Models/PluginRecord.cs
Normal file
122
src/Plugin/StellaOps.Plugin.Registry/Models/PluginRecord.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin record in the database.
|
||||
/// </summary>
|
||||
public sealed class PluginRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin identifier (from manifest).
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin display name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin version (semver).
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin vendor/author.
|
||||
/// </summary>
|
||||
public required string Vendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SPDX license identifier.
|
||||
/// </summary>
|
||||
public string? LicenseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin trust level.
|
||||
/// </summary>
|
||||
public required PluginTrustLevel TrustLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin capabilities flags.
|
||||
/// </summary>
|
||||
public required PluginCapabilities Capabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin lifecycle state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional status message.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current health status.
|
||||
/// </summary>
|
||||
public required HealthStatus HealthStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last health check timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastHealthCheck { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Consecutive health check failures.
|
||||
/// </summary>
|
||||
public int HealthCheckFailures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (bundled, installed, discovered).
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly file path.
|
||||
/// </summary>
|
||||
public string? AssemblyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point type name.
|
||||
/// </summary>
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full manifest as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime information.
|
||||
/// </summary>
|
||||
public JsonDocument? RuntimeInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record last update timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin load timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LoadedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Runs database migrations for the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryMigrationRunner
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PluginRegistryMigrationRunner> _logger;
|
||||
private readonly PluginRegistryOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new migration runner instance.
|
||||
/// </summary>
|
||||
public PluginRegistryMigrationRunner(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PluginRegistryMigrationRunner> logger,
|
||||
IOptions<PluginRegistryOptions> options)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public async Task RunMigrationsAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Running plugin registry migrations...");
|
||||
|
||||
await EnsureMigrationTableExistsAsync(ct);
|
||||
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(ct);
|
||||
var pendingMigrations = GetPendingMigrations(appliedMigrations);
|
||||
|
||||
if (pendingMigrations.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No pending migrations");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var migration in pendingMigrations)
|
||||
{
|
||||
await ApplyMigrationAsync(migration, ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applied {Count} migrations", pendingMigrations.Count);
|
||||
}
|
||||
|
||||
private async Task EnsureMigrationTableExistsAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
CREATE TABLE IF NOT EXISTS {_options.SchemaName}.plugin_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_name VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""";
|
||||
|
||||
// First ensure schema exists
|
||||
var schemaSql = $"CREATE SCHEMA IF NOT EXISTS {_options.SchemaName}";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
await using var schemaCmd = new NpgsqlCommand(schemaSql, conn);
|
||||
await schemaCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetAppliedMigrationsAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT migration_name FROM {_options.SchemaName}.plugin_migrations";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
applied.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
private List<(string Name, string Sql)> GetPendingMigrations(HashSet<string> appliedMigrations)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourcePrefix = "StellaOps.Plugin.Registry.Migrations.";
|
||||
|
||||
var pending = new List<(string Name, string Sql)>();
|
||||
|
||||
foreach (var resourceName in assembly.GetManifestResourceNames()
|
||||
.Where(n => n.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase) && n.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(n => n))
|
||||
{
|
||||
var migrationName = Path.GetFileNameWithoutExtension(resourceName[(resourcePrefix.Length)..]);
|
||||
|
||||
if (appliedMigrations.Contains(migrationName))
|
||||
continue;
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = reader.ReadToEnd();
|
||||
|
||||
pending.Add((migrationName, sql));
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync((string Name, string Sql) migration, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Applying migration: {MigrationName}", migration.Name);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Execute migration SQL
|
||||
await using var cmd = new NpgsqlCommand(migration.Sql, conn, transaction);
|
||||
cmd.CommandTimeout = (int)_options.CommandTimeout.TotalSeconds;
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Record migration
|
||||
await using var recordCmd = new NpgsqlCommand(
|
||||
$"INSERT INTO {_options.SchemaName}.plugin_migrations (migration_name) VALUES (@name)",
|
||||
conn,
|
||||
transaction);
|
||||
recordCmd.Parameters.AddWithValue("name", migration.Name);
|
||||
await recordCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
_logger.LogInformation("Applied migration: {MigrationName}", migration.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to apply migration: {MigrationName}", migration.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "PluginRegistry";
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL connection string.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Schema name for plugin tables.
|
||||
/// </summary>
|
||||
public string SchemaName { get; set; } = "platform";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum health history records to retain per plugin.
|
||||
/// </summary>
|
||||
public int MaxHealthHistoryRecords { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Health history retention period.
|
||||
/// </summary>
|
||||
public TimeSpan HealthHistoryRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to run migrations on startup.
|
||||
/// </summary>
|
||||
public bool RunMigrationsOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout for database operations.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
734
src/Plugin/StellaOps.Plugin.Registry/PostgresPluginRegistry.cs
Normal file
734
src/Plugin/StellaOps.Plugin.Registry/PostgresPluginRegistry.cs
Normal file
@@ -0,0 +1,734 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PostgresPluginRegistry : IPluginRegistry
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresPluginRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PluginRegistryOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL plugin registry instance.
|
||||
/// </summary>
|
||||
public PostgresPluginRegistry(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresPluginRegistry> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PluginRegistryOptions> options)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugins (
|
||||
plugin_id, name, version, vendor, description, license_id,
|
||||
trust_level, capabilities, capability_details, source,
|
||||
assembly_path, entry_point, status, manifest, created_at, updated_at, loaded_at
|
||||
) VALUES (
|
||||
@plugin_id, @name, @version, @vendor, @description, @license_id,
|
||||
@trust_level, @capabilities, @capability_details::jsonb, @source,
|
||||
@assembly_path, @entry_point, @status, @manifest::jsonb, @now, @now, @now
|
||||
)
|
||||
ON CONFLICT (plugin_id, version) DO UPDATE SET
|
||||
status = @status,
|
||||
updated_at = @now,
|
||||
loaded_at = @now
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", plugin.Info.Id);
|
||||
cmd.Parameters.AddWithValue("name", plugin.Info.Name);
|
||||
cmd.Parameters.AddWithValue("version", plugin.Info.Version);
|
||||
cmd.Parameters.AddWithValue("vendor", plugin.Info.Vendor);
|
||||
cmd.Parameters.AddWithValue("description", (object?)plugin.Info.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("license_id", (object?)plugin.Info.LicenseId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("trust_level", plugin.TrustLevel.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("capabilities", plugin.Capabilities.ToStringArray());
|
||||
cmd.Parameters.AddWithValue("capability_details", "{}");
|
||||
cmd.Parameters.AddWithValue("source", "installed");
|
||||
cmd.Parameters.AddWithValue("assembly_path", (object?)plugin.Manifest?.AssemblyPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("entry_point", (object?)plugin.Manifest?.EntryPoint ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", plugin.State.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("manifest", plugin.Manifest != null
|
||||
? JsonSerializer.Serialize(plugin.Manifest, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var record = MapPluginRecord(reader);
|
||||
|
||||
// Register capabilities
|
||||
if (plugin.Manifest?.Capabilities.Count > 0)
|
||||
{
|
||||
await reader.CloseAsync();
|
||||
var capRecords = plugin.Manifest.Capabilities.Select(c => new PluginCapabilityRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PluginId = record.Id,
|
||||
CapabilityType = c.Type,
|
||||
CapabilityId = c.Id ?? c.Type,
|
||||
DisplayName = c.DisplayName,
|
||||
Description = c.Description,
|
||||
Metadata = c.Metadata,
|
||||
IsEnabled = true,
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await RegisterCapabilitiesAsync(record.Id, capRecords, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Registered plugin {PluginId} with DB ID {DbId}", plugin.Info.Id, record.Id);
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to register plugin {plugin.Info.Id}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugins
|
||||
SET status = @status, status_message = @message, updated_at = @now
|
||||
WHERE plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("message", (object?)message ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct);
|
||||
if (rows > 0)
|
||||
{
|
||||
_logger.LogDebug("Updated plugin {PluginId} status to {Status}", pluginId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugins
|
||||
SET health_status = @health_status,
|
||||
last_health_check = @now,
|
||||
updated_at = @now,
|
||||
health_check_failures = CASE
|
||||
WHEN @health_status = 'healthy' THEN 0
|
||||
ELSE health_check_failures + 1
|
||||
END
|
||||
WHERE plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("health_status", status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Record health history
|
||||
if (result != null)
|
||||
{
|
||||
await RecordHealthCheckAsync(pluginId, result, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnregisterAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"DELETE FROM {_options.SchemaName}.plugins WHERE plugin_id = @plugin_id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugins WHERE plugin_id = @plugin_id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapPluginRecord(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugins ORDER BY name";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugins
|
||||
WHERE status = @status
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct)
|
||||
{
|
||||
var capabilityStrings = capability.ToStringArray();
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugins
|
||||
WHERE capabilities && @capabilities
|
||||
AND status = 'active'
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("capabilities", capabilityStrings);
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(
|
||||
string capabilityType,
|
||||
string? capabilityId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT p.* FROM {_options.SchemaName}.plugins p
|
||||
INNER JOIN {_options.SchemaName}.plugin_capabilities c ON c.plugin_id = p.id
|
||||
WHERE c.capability_type = @capability_type
|
||||
AND c.is_enabled = TRUE
|
||||
AND p.status = 'active'
|
||||
""";
|
||||
|
||||
if (capabilityId != null)
|
||||
{
|
||||
sql += " AND c.capability_id = @capability_id";
|
||||
}
|
||||
|
||||
sql += " ORDER BY p.name";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("capability_type", capabilityType);
|
||||
if (capabilityId != null)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("capability_id", capabilityId);
|
||||
}
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RegisterCapabilitiesAsync(
|
||||
Guid pluginDbId,
|
||||
IEnumerable<PluginCapabilityRecord> capabilities,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_capabilities (
|
||||
id, plugin_id, capability_type, capability_id,
|
||||
display_name, description, config_schema, metadata, is_enabled, created_at
|
||||
) VALUES (
|
||||
@id, @plugin_id, @capability_type, @capability_id,
|
||||
@display_name, @description, @config_schema::jsonb, @metadata::jsonb, @is_enabled, @created_at
|
||||
)
|
||||
ON CONFLICT (plugin_id, capability_type, capability_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
config_schema = EXCLUDED.config_schema,
|
||||
metadata = EXCLUDED.metadata
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", cap.Id);
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginDbId);
|
||||
cmd.Parameters.AddWithValue("capability_type", cap.CapabilityType);
|
||||
cmd.Parameters.AddWithValue("capability_id", cap.CapabilityId);
|
||||
cmd.Parameters.AddWithValue("display_name", (object?)cap.DisplayName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)cap.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("config_schema", cap.ConfigSchema != null
|
||||
? JsonSerializer.Serialize(cap.ConfigSchema, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("metadata", cap.Metadata != null
|
||||
? JsonSerializer.Serialize(cap.Metadata, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_enabled", cap.IsEnabled);
|
||||
cmd.Parameters.AddWithValue("created_at", cap.CreatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT c.* FROM {_options.SchemaName}.plugin_capabilities c
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = c.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
ORDER BY c.capability_type, c.capability_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
var results = new List<PluginCapabilityRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapCapabilityRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ========== Instance Management ==========
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_instances (
|
||||
plugin_id, tenant_id, instance_name, config, secrets_path,
|
||||
resource_limits, enabled, status, created_at, updated_at
|
||||
)
|
||||
SELECT p.id, @tenant_id, @instance_name, @config::jsonb, @secrets_path,
|
||||
@resource_limits::jsonb, TRUE, 'pending', @now, @now
|
||||
FROM {_options.SchemaName}.plugins p
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", request.PluginId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", (object?)request.TenantId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("instance_name", (object?)request.InstanceName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("config", JsonSerializer.Serialize(request.Config, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("secrets_path", (object?)request.SecretsPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("resource_limits", request.ResourceLimits != null
|
||||
? JsonSerializer.Serialize(request.ResourceLimits, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var record = MapInstanceRecord(reader);
|
||||
_logger.LogDebug("Created instance {InstanceId} for plugin {PluginId}", record.Id, request.PluginId);
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to create instance for plugin {request.PluginId}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugin_instances WHERE id = @id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapInstanceRecord(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugin_instances
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var results = new List<PluginInstanceRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapInstanceRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT i.* FROM {_options.SchemaName}.plugin_instances i
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = i.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
ORDER BY i.created_at
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
var results = new List<PluginInstanceRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapInstanceRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugin_instances
|
||||
SET config = @config::jsonb, updated_at = @now
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
cmd.Parameters.AddWithValue("config", JsonSerializer.Serialize(config, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_logger.LogDebug("Updated config for instance {InstanceId}", instanceId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugin_instances
|
||||
SET enabled = @enabled, updated_at = @now
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
cmd.Parameters.AddWithValue("enabled", enabled);
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_logger.LogDebug("Set instance {InstanceId} enabled={Enabled}", instanceId, enabled);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"DELETE FROM {_options.SchemaName}.plugin_instances WHERE id = @id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
_logger.LogDebug("Deleted instance {InstanceId}", instanceId);
|
||||
}
|
||||
|
||||
// ========== Health History ==========
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_health_history (
|
||||
plugin_id, checked_at, status, response_time_ms, details, error_message, created_at
|
||||
)
|
||||
SELECT p.id, @checked_at, @status, @response_time_ms, @details::jsonb, @error_message, @checked_at
|
||||
FROM {_options.SchemaName}.plugins p
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("checked_at", now);
|
||||
cmd.Parameters.AddWithValue("status", result.Status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("response_time_ms", (int)(result.Duration?.TotalMilliseconds ?? 0));
|
||||
cmd.Parameters.AddWithValue("details", result.Details != null
|
||||
? JsonSerializer.Serialize(result.Details, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("error_message", (object?)result.Message ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT h.*, p.plugin_id as plugin_string_id
|
||||
FROM {_options.SchemaName}.plugin_health_history h
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = h.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
AND h.checked_at >= @since
|
||||
ORDER BY h.checked_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("since", since);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var results = new List<PluginHealthRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapHealthRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ========== Mapping ==========
|
||||
|
||||
private static PluginRecord MapPluginRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetString(reader.GetOrdinal("plugin_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Version = reader.GetString(reader.GetOrdinal("version")),
|
||||
Vendor = reader.GetString(reader.GetOrdinal("vendor")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("description")),
|
||||
LicenseId = reader.IsDBNull(reader.GetOrdinal("license_id"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("license_id")),
|
||||
TrustLevel = Enum.Parse<PluginTrustLevel>(reader.GetString(reader.GetOrdinal("trust_level")), ignoreCase: true),
|
||||
Capabilities = PluginCapabilitiesExtensions.FromStringArray(
|
||||
reader.GetFieldValue<string[]>(reader.GetOrdinal("capabilities"))),
|
||||
Status = Enum.Parse<PluginLifecycleState>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
|
||||
StatusMessage = reader.IsDBNull(reader.GetOrdinal("status_message"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("status_message")),
|
||||
HealthStatus = reader.IsDBNull(reader.GetOrdinal("health_status"))
|
||||
? HealthStatus.Unknown
|
||||
: Enum.Parse<HealthStatus>(reader.GetString(reader.GetOrdinal("health_status")), ignoreCase: true),
|
||||
LastHealthCheck = reader.IsDBNull(reader.GetOrdinal("last_health_check"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_health_check")),
|
||||
HealthCheckFailures = reader.IsDBNull(reader.GetOrdinal("health_check_failures"))
|
||||
? 0
|
||||
: reader.GetInt32(reader.GetOrdinal("health_check_failures")),
|
||||
Source = reader.GetString(reader.GetOrdinal("source")),
|
||||
AssemblyPath = reader.IsDBNull(reader.GetOrdinal("assembly_path"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("assembly_path")),
|
||||
EntryPoint = reader.IsDBNull(reader.GetOrdinal("entry_point"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("entry_point")),
|
||||
Manifest = reader.IsDBNull(reader.GetOrdinal("manifest"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("manifest"))),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
LoadedAt = reader.IsDBNull(reader.GetOrdinal("loaded_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("loaded_at"))
|
||||
};
|
||||
|
||||
private static PluginCapabilityRecord MapCapabilityRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
CapabilityType = reader.GetString(reader.GetOrdinal("capability_type")),
|
||||
CapabilityId = reader.GetString(reader.GetOrdinal("capability_id")),
|
||||
DisplayName = reader.IsDBNull(reader.GetOrdinal("display_name"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("display_name")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("description")),
|
||||
ConfigSchema = reader.IsDBNull(reader.GetOrdinal("config_schema"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("config_schema"))),
|
||||
IsEnabled = reader.GetBoolean(reader.GetOrdinal("is_enabled")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
|
||||
private static PluginInstanceRecord MapInstanceRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
TenantId = reader.IsDBNull(reader.GetOrdinal("tenant_id"))
|
||||
? null
|
||||
: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
InstanceName = reader.IsDBNull(reader.GetOrdinal("instance_name"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("instance_name")),
|
||||
Config = JsonDocument.Parse(reader.GetString(reader.GetOrdinal("config"))),
|
||||
SecretsPath = reader.IsDBNull(reader.GetOrdinal("secrets_path"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("secrets_path")),
|
||||
ResourceLimits = reader.IsDBNull(reader.GetOrdinal("resource_limits"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("resource_limits"))),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
LastUsedAt = reader.IsDBNull(reader.GetOrdinal("last_used_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_used_at")),
|
||||
InvocationCount = reader.IsDBNull(reader.GetOrdinal("invocation_count"))
|
||||
? 0
|
||||
: reader.GetInt64(reader.GetOrdinal("invocation_count")),
|
||||
ErrorCount = reader.IsDBNull(reader.GetOrdinal("error_count"))
|
||||
? 0
|
||||
: reader.GetInt64(reader.GetOrdinal("error_count")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
};
|
||||
|
||||
private static PluginHealthRecord MapHealthRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
PluginStringId = reader.IsDBNull(reader.GetOrdinal("plugin_string_id"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("plugin_string_id")),
|
||||
CheckedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("checked_at")),
|
||||
Status = Enum.Parse<HealthStatus>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
|
||||
ResponseTimeMs = reader.IsDBNull(reader.GetOrdinal("response_time_ms"))
|
||||
? null
|
||||
: reader.GetInt32(reader.GetOrdinal("response_time_ms")),
|
||||
Details = reader.IsDBNull(reader.GetOrdinal("details"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("details"))),
|
||||
ErrorMessage = reader.IsDBNull(reader.GetOrdinal("error_message"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("error_message")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>StellaOps.Plugin.Registry</PackageId>
|
||||
<Description>Database-backed plugin registry for persistent plugin management</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user