release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -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;
}
}

View 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);

View 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();
}
}

View File

@@ -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';

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View 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"))
};
}

View File

@@ -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>