release orchestrator v1 draft and build fixes
This commit is contained in:
221
src/Plugin/StellaOps.Plugin.Host/Context/PluginConfiguration.cs
Normal file
221
src/Plugin/StellaOps.Plugin.Host/Context/PluginConfiguration.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginConfiguration.
|
||||
/// </summary>
|
||||
public sealed class PluginConfiguration : IPluginConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, object> _values;
|
||||
private readonly PluginManifest _manifest;
|
||||
private readonly Func<string, CancellationToken, Task<string?>>? _secretProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin configuration.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest (contains default config).</param>
|
||||
/// <param name="overrides">Optional configuration overrides.</param>
|
||||
/// <param name="secretProvider">Optional secret provider function.</param>
|
||||
public PluginConfiguration(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, object>? overrides = null,
|
||||
Func<string, CancellationToken, Task<string?>>? secretProvider = null)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_secretProvider = secretProvider;
|
||||
_values = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Load defaults from manifest
|
||||
if (manifest.DefaultConfig != null)
|
||||
{
|
||||
LoadFromJsonDocument(manifest.DefaultConfig);
|
||||
}
|
||||
|
||||
// Apply overrides
|
||||
if (overrides != null)
|
||||
{
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
_values[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetValue<T>(string key, T? defaultValue = default)
|
||||
{
|
||||
if (!_values.TryGetValue(key, out var value))
|
||||
return defaultValue;
|
||||
|
||||
var converted = ConvertValue<T>(value);
|
||||
return converted ?? defaultValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Bind<T>(string? sectionKey = null) where T : class, new()
|
||||
{
|
||||
var result = new T();
|
||||
var prefix = string.IsNullOrEmpty(sectionKey) ? "" : sectionKey + ":";
|
||||
var type = typeof(T);
|
||||
|
||||
foreach (var property in type.GetProperties())
|
||||
{
|
||||
if (!property.CanWrite)
|
||||
continue;
|
||||
|
||||
var key = string.IsNullOrEmpty(prefix) ? property.Name : $"{prefix}{property.Name}";
|
||||
|
||||
if (_values.TryGetValue(key, out var value))
|
||||
{
|
||||
try
|
||||
{
|
||||
var converted = ConvertValueToType(value, property.PropertyType);
|
||||
if (converted != null)
|
||||
{
|
||||
property.SetValue(result, converted);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip properties that can't be converted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
|
||||
{
|
||||
if (_secretProvider != null)
|
||||
{
|
||||
return await _secretProvider(secretName, ct);
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
return Environment.GetEnvironmentVariable(secretName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasKey(string key)
|
||||
{
|
||||
return _values.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all configuration values.
|
||||
/// </summary>
|
||||
/// <returns>All configuration values.</returns>
|
||||
public IReadOnlyDictionary<string, object> GetAll()
|
||||
{
|
||||
return _values;
|
||||
}
|
||||
|
||||
private void LoadFromJsonDocument(JsonDocument document)
|
||||
{
|
||||
LoadFromJsonElement("", document.RootElement);
|
||||
}
|
||||
|
||||
private void LoadFromJsonElement(string prefix, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix)
|
||||
? property.Name
|
||||
: $"{prefix}:{property.Name}";
|
||||
LoadFromJsonElement(key, property.Value);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
var index = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var key = $"{prefix}:{index}";
|
||||
LoadFromJsonElement(key, item);
|
||||
index++;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
_values[prefix] = element.GetString()!;
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
_values[prefix] = longValue;
|
||||
else if (element.TryGetDouble(out var doubleValue))
|
||||
_values[prefix] = doubleValue;
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
_values[prefix] = true;
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
_values[prefix] = false;
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
// Skip null values
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static T? ConvertValue<T>(object value)
|
||||
{
|
||||
return (T?)ConvertValueToType(value, typeof(T));
|
||||
}
|
||||
|
||||
private static object? ConvertValueToType(object value, Type targetType)
|
||||
{
|
||||
if (value.GetType() == targetType)
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
var json = jsonElement.GetRawText();
|
||||
return JsonSerializer.Deserialize(json, targetType);
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(string))
|
||||
return value.ToString();
|
||||
|
||||
if (underlyingType == typeof(int))
|
||||
return Convert.ToInt32(value);
|
||||
|
||||
if (underlyingType == typeof(long))
|
||||
return Convert.ToInt64(value);
|
||||
|
||||
if (underlyingType == typeof(double))
|
||||
return Convert.ToDouble(value);
|
||||
|
||||
if (underlyingType == typeof(bool))
|
||||
return Convert.ToBoolean(value);
|
||||
|
||||
if (underlyingType == typeof(TimeSpan) && value is string strValue)
|
||||
return TimeSpan.Parse(strValue);
|
||||
|
||||
if (underlyingType == typeof(Guid) && value is string guidStr)
|
||||
return Guid.Parse(guidStr);
|
||||
|
||||
return Convert.ChangeType(value, underlyingType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/Plugin/StellaOps.Plugin.Host/Context/PluginContext.cs
Normal file
130
src/Plugin/StellaOps.Plugin.Host/Context/PluginContext.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginContext provided to plugins during initialization.
|
||||
/// </summary>
|
||||
public sealed class PluginContext : IPluginContext
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger Logger { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginServices Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken ShutdownToken { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The plugin manifest.
|
||||
/// </summary>
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level of the plugin.
|
||||
/// </summary>
|
||||
public PluginTrustLevel TrustLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin context.
|
||||
/// </summary>
|
||||
public PluginContext(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
IPluginConfiguration configuration,
|
||||
IPluginLogger logger,
|
||||
IPluginServices services,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken shutdownToken,
|
||||
Guid? tenantId = null)
|
||||
{
|
||||
Manifest = manifest;
|
||||
TrustLevel = trustLevel;
|
||||
Configuration = configuration;
|
||||
Logger = logger;
|
||||
Services = services;
|
||||
TimeProvider = timeProvider;
|
||||
ShutdownToken = shutdownToken;
|
||||
TenantId = tenantId;
|
||||
InstanceId = Guid.NewGuid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating plugin contexts.
|
||||
/// </summary>
|
||||
public sealed class PluginContextFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin context factory.
|
||||
/// </summary>
|
||||
public PluginContextFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
PluginHostOptions options,
|
||||
IServiceProvider serviceProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_options = options;
|
||||
_serviceProvider = serviceProvider;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a plugin context for a manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="trustLevel">Trust level.</param>
|
||||
/// <param name="shutdownToken">Shutdown token.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <returns>The created context.</returns>
|
||||
public PluginContext Create(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken shutdownToken,
|
||||
Guid? tenantId = null)
|
||||
{
|
||||
var pluginId = manifest.Info.Id;
|
||||
|
||||
// Create configuration
|
||||
var configuration = new PluginConfiguration(manifest);
|
||||
|
||||
// Create logger
|
||||
var innerLogger = _loggerFactory.CreateLogger($"Plugin.{pluginId}");
|
||||
var logger = new PluginLogger(innerLogger, pluginId);
|
||||
|
||||
// Create services wrapper
|
||||
var services = new PluginServices(_serviceProvider, trustLevel);
|
||||
|
||||
return new PluginContext(
|
||||
manifest,
|
||||
trustLevel,
|
||||
configuration,
|
||||
logger,
|
||||
services,
|
||||
_timeProvider,
|
||||
shutdownToken,
|
||||
tenantId);
|
||||
}
|
||||
}
|
||||
112
src/Plugin/StellaOps.Plugin.Host/Context/PluginLogger.cs
Normal file
112
src/Plugin/StellaOps.Plugin.Host/Context/PluginLogger.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginLogger that wraps a Microsoft.Extensions.Logging logger.
|
||||
/// </summary>
|
||||
public sealed class PluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _pluginId;
|
||||
private readonly Dictionary<string, object> _properties;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin logger.
|
||||
/// </summary>
|
||||
/// <param name="logger">The underlying logger.</param>
|
||||
/// <param name="pluginId">The plugin ID for context.</param>
|
||||
public PluginLogger(ILogger logger, string pluginId)
|
||||
: this(logger, pluginId, new Dictionary<string, object>())
|
||||
{
|
||||
}
|
||||
|
||||
private PluginLogger(ILogger logger, string pluginId, Dictionary<string, object> properties)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginId = pluginId;
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
_logger.Log(level, message, args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
_logger.Log(level, exception, message, args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProperties = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
|
||||
// Create a logger that includes the property in scope
|
||||
return new PropertyScopedPluginLogger(_logger, _pluginId, newProperties);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return WithProperty("Operation", operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel level) => _logger.IsEnabled(level);
|
||||
|
||||
/// <summary>
|
||||
/// Plugin logger with property scope support.
|
||||
/// </summary>
|
||||
private sealed class PropertyScopedPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _pluginId;
|
||||
private readonly Dictionary<string, object> _properties;
|
||||
|
||||
public PropertyScopedPluginLogger(
|
||||
ILogger logger,
|
||||
string pluginId,
|
||||
Dictionary<string, object> properties)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginId = pluginId;
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
using var scope = _logger.BeginScope(_properties);
|
||||
_logger.Log(level, message, args);
|
||||
}
|
||||
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
using var scope = _logger.BeginScope(_properties);
|
||||
_logger.Log(level, exception, message, args);
|
||||
}
|
||||
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProperties = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
return new PropertyScopedPluginLogger(_logger, _pluginId, newProperties);
|
||||
}
|
||||
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return WithProperty("Operation", operationName);
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel level) => _logger.IsEnabled(level);
|
||||
}
|
||||
}
|
||||
120
src/Plugin/StellaOps.Plugin.Host/Context/PluginServices.cs
Normal file
120
src/Plugin/StellaOps.Plugin.Host/Context/PluginServices.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginServices that provides access to platform services.
|
||||
/// </summary>
|
||||
public sealed class PluginServices : IPluginServices, IAsyncDisposable
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly PluginTrustLevel _trustLevel;
|
||||
private readonly HashSet<Type> _restrictedTypes;
|
||||
private readonly IServiceScope? _scope;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin services wrapper.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The underlying service provider.</param>
|
||||
/// <param name="trustLevel">Trust level of the plugin.</param>
|
||||
public PluginServices(IServiceProvider serviceProvider, PluginTrustLevel trustLevel)
|
||||
: this(serviceProvider, trustLevel, null)
|
||||
{
|
||||
}
|
||||
|
||||
private PluginServices(IServiceProvider serviceProvider, PluginTrustLevel trustLevel, IServiceScope? scope)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_trustLevel = trustLevel;
|
||||
_scope = scope;
|
||||
_restrictedTypes = GetRestrictedTypes(trustLevel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetService<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetService<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T GetRequiredService<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetRequiredService<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<T> GetServices<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetServices<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncDisposable CreateScope(out IPluginServices scopedServices)
|
||||
{
|
||||
var scope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
|
||||
var scoped = new PluginServices(scope.ServiceProvider, _trustLevel, scope);
|
||||
scopedServices = scoped;
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scope is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateAccess(Type serviceType)
|
||||
{
|
||||
if (IsRestricted(serviceType))
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Plugin does not have permission to access service '{serviceType.Name}'. " +
|
||||
$"Trust level: {_trustLevel}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRestricted(Type serviceType)
|
||||
{
|
||||
// Built-in plugins have full access
|
||||
if (_trustLevel == PluginTrustLevel.BuiltIn)
|
||||
return false;
|
||||
|
||||
// Check if the type or any of its interfaces are restricted
|
||||
if (_restrictedTypes.Contains(serviceType))
|
||||
return true;
|
||||
|
||||
foreach (var iface in serviceType.GetInterfaces())
|
||||
{
|
||||
if (_restrictedTypes.Contains(iface))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HashSet<Type> GetRestrictedTypes(PluginTrustLevel trustLevel)
|
||||
{
|
||||
var restricted = new HashSet<Type>();
|
||||
|
||||
// Untrusted plugins have more restrictions
|
||||
if (trustLevel == PluginTrustLevel.Untrusted)
|
||||
{
|
||||
// Add types that untrusted plugins cannot access
|
||||
// This will be populated based on security requirements
|
||||
}
|
||||
|
||||
return restricted;
|
||||
}
|
||||
}
|
||||
225
src/Plugin/StellaOps.Plugin.Host/Dependencies/DependencyGraph.cs
Normal file
225
src/Plugin/StellaOps.Plugin.Host/Dependencies/DependencyGraph.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a directed graph of plugin dependencies.
|
||||
/// </summary>
|
||||
public sealed class DependencyGraph
|
||||
{
|
||||
private readonly HashSet<string> _nodes = [];
|
||||
private readonly Dictionary<string, HashSet<string>> _edges = new();
|
||||
private readonly Dictionary<string, HashSet<string>> _reverseEdges = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all nodes in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> Nodes => _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of nodes in the graph.
|
||||
/// </summary>
|
||||
public int NodeCount => _nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of edges in the graph.
|
||||
/// </summary>
|
||||
public int EdgeCount => _edges.Values.Sum(e => e.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a node to the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
public void AddNode(string node)
|
||||
{
|
||||
if (_nodes.Add(node))
|
||||
{
|
||||
_edges[node] = [];
|
||||
_reverseEdges[node] = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a node exists in the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>True if the node exists.</returns>
|
||||
public bool HasNode(string node)
|
||||
{
|
||||
return _nodes.Contains(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from one node to another.
|
||||
/// </summary>
|
||||
/// <param name="from">The source node.</param>
|
||||
/// <param name="to">The target node.</param>
|
||||
public void AddEdge(string from, string to)
|
||||
{
|
||||
AddNode(from);
|
||||
AddNode(to);
|
||||
|
||||
_edges[from].Add(to);
|
||||
_reverseEdges[to].Add(from);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nodes that depend on a given node (outgoing edges).
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of dependent node IDs.</returns>
|
||||
public IReadOnlySet<string> GetDependents(string node)
|
||||
{
|
||||
return _edges.TryGetValue(node, out var dependents) ? dependents : new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nodes that a given node depends on (incoming edges).
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of dependency node IDs.</returns>
|
||||
public IReadOnlySet<string> GetDependencies(string node)
|
||||
{
|
||||
return _reverseEdges.TryGetValue(node, out var dependencies) ? dependencies : new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets nodes with no dependencies (roots of the dependency tree).
|
||||
/// </summary>
|
||||
/// <returns>Set of root node IDs.</returns>
|
||||
public IReadOnlySet<string> GetRoots()
|
||||
{
|
||||
return _nodes
|
||||
.Where(n => !_reverseEdges.TryGetValue(n, out var deps) || deps.Count == 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets nodes with no dependents (leaves of the dependency tree).
|
||||
/// </summary>
|
||||
/// <returns>Set of leaf node IDs.</returns>
|
||||
public IReadOnlySet<string> GetLeaves()
|
||||
{
|
||||
return _nodes
|
||||
.Where(n => !_edges.TryGetValue(n, out var deps) || deps.Count == 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all transitive dependencies for a node.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of all transitive dependency IDs.</returns>
|
||||
public IReadOnlySet<string> GetAllDependencies(string node)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var dep in GetDependencies(node))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (result.Add(current))
|
||||
{
|
||||
foreach (var dep in GetDependencies(current))
|
||||
{
|
||||
if (!result.Contains(dep))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all transitive dependents for a node.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of all transitive dependent IDs.</returns>
|
||||
public IReadOnlySet<string> GetAllDependents(string node)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var dep in GetDependents(node))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (result.Add(current))
|
||||
{
|
||||
foreach (var dep in GetDependents(current))
|
||||
{
|
||||
if (!result.Contains(dep))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a node and all its edges from the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
public void RemoveNode(string node)
|
||||
{
|
||||
if (!_nodes.Remove(node))
|
||||
return;
|
||||
|
||||
// Remove outgoing edges
|
||||
if (_edges.TryGetValue(node, out var outgoing))
|
||||
{
|
||||
foreach (var target in outgoing)
|
||||
{
|
||||
_reverseEdges[target].Remove(node);
|
||||
}
|
||||
_edges.Remove(node);
|
||||
}
|
||||
|
||||
// Remove incoming edges
|
||||
if (_reverseEdges.TryGetValue(node, out var incoming))
|
||||
{
|
||||
foreach (var source in incoming)
|
||||
{
|
||||
_edges[source].Remove(node);
|
||||
}
|
||||
_reverseEdges.Remove(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the graph.
|
||||
/// </summary>
|
||||
/// <returns>A new graph with the same nodes and edges.</returns>
|
||||
public DependencyGraph Clone()
|
||||
{
|
||||
var clone = new DependencyGraph();
|
||||
|
||||
foreach (var node in _nodes)
|
||||
{
|
||||
clone.AddNode(node);
|
||||
}
|
||||
|
||||
foreach (var kvp in _edges)
|
||||
{
|
||||
foreach (var target in kvp.Value)
|
||||
{
|
||||
clone.AddEdge(kvp.Key, target);
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves plugin dependencies and determines load order.
|
||||
/// </summary>
|
||||
public interface IPluginDependencyResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the order in which plugins should be loaded based on dependencies.
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests to order.</param>
|
||||
/// <returns>Manifests in load order (dependencies first).</returns>
|
||||
IReadOnlyList<PluginManifest> ResolveLoadOrder(IEnumerable<PluginManifest> manifests);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the order in which plugins should be unloaded (reverse of load order).
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests to order.</param>
|
||||
/// <returns>Plugin IDs in unload order (dependents first).</returns>
|
||||
IReadOnlyList<string> ResolveUnloadOrder(IEnumerable<PluginManifest?> manifests);
|
||||
|
||||
/// <summary>
|
||||
/// Check if all dependencies for a plugin are satisfied.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="availablePlugins">Available plugin IDs and versions.</param>
|
||||
/// <returns>True if all required dependencies are available.</returns>
|
||||
bool AreDependenciesSatisfied(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins);
|
||||
|
||||
/// <summary>
|
||||
/// Get missing dependencies for a plugin.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="availablePlugins">Available plugin IDs and versions.</param>
|
||||
/// <returns>List of missing dependencies.</returns>
|
||||
IReadOnlyList<MissingDependency> GetMissingDependencies(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins);
|
||||
|
||||
/// <summary>
|
||||
/// Validate the dependency graph for circular dependencies.
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests.</param>
|
||||
/// <returns>List of circular dependency errors, empty if valid.</returns>
|
||||
IReadOnlyList<CircularDependencyError> ValidateDependencyGraph(IEnumerable<PluginManifest> manifests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a missing plugin dependency.
|
||||
/// </summary>
|
||||
/// <param name="PluginId">ID of the plugin requiring the dependency.</param>
|
||||
/// <param name="RequiredPluginId">ID of the missing plugin.</param>
|
||||
/// <param name="RequiredVersion">Required version constraint.</param>
|
||||
/// <param name="IsOptional">Whether the dependency is optional.</param>
|
||||
public sealed record MissingDependency(
|
||||
string PluginId,
|
||||
string RequiredPluginId,
|
||||
string? RequiredVersion,
|
||||
bool IsOptional);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a circular dependency error.
|
||||
/// </summary>
|
||||
/// <param name="Cycle">The plugins involved in the circular dependency.</param>
|
||||
public sealed record CircularDependencyError(IReadOnlyList<string> Cycle)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the cycle.
|
||||
/// </summary>
|
||||
public string Description => string.Join(" -> ", Cycle) + " -> " + Cycle[0];
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves plugin dependencies using topological sorting.
|
||||
/// </summary>
|
||||
public sealed class PluginDependencyResolver : IPluginDependencyResolver
|
||||
{
|
||||
private readonly ILogger<PluginDependencyResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin dependency resolver.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public PluginDependencyResolver(ILogger<PluginDependencyResolver> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PluginManifest> ResolveLoadOrder(IEnumerable<PluginManifest> manifests)
|
||||
{
|
||||
var manifestList = manifests.ToList();
|
||||
var graph = BuildDependencyGraph(manifestList);
|
||||
|
||||
// Validate for cycles
|
||||
var cycles = DetectCycles(graph);
|
||||
if (cycles.Count > 0)
|
||||
{
|
||||
var cycleDescriptions = string.Join("; ", cycles.Select(c => c.Description));
|
||||
throw new InvalidOperationException($"Circular dependencies detected: {cycleDescriptions}");
|
||||
}
|
||||
|
||||
// Topological sort
|
||||
var sorted = TopologicalSort(graph);
|
||||
|
||||
// Map back to manifests
|
||||
var manifestLookup = manifestList.ToDictionary(m => m.Info.Id);
|
||||
var result = new List<PluginManifest>();
|
||||
|
||||
foreach (var pluginId in sorted)
|
||||
{
|
||||
if (manifestLookup.TryGetValue(pluginId, out var manifest))
|
||||
{
|
||||
result.Add(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Resolved load order for {Count} plugins", result.Count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> ResolveUnloadOrder(IEnumerable<PluginManifest?> manifests)
|
||||
{
|
||||
var validManifests = manifests.Where(m => m != null).Cast<PluginManifest>().ToList();
|
||||
var loadOrder = ResolveLoadOrder(validManifests);
|
||||
|
||||
// Reverse the load order to get unload order
|
||||
return loadOrder.Select(m => m.Info.Id).Reverse().ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AreDependenciesSatisfied(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins)
|
||||
{
|
||||
var missing = GetMissingDependencies(manifest, availablePlugins);
|
||||
return missing.All(m => m.IsOptional);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MissingDependency> GetMissingDependencies(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins)
|
||||
{
|
||||
var missing = new List<MissingDependency>();
|
||||
|
||||
foreach (var dependency in manifest.Dependencies)
|
||||
{
|
||||
if (!availablePlugins.TryGetValue(dependency.PluginId, out var availableVersion))
|
||||
{
|
||||
missing.Add(new MissingDependency(
|
||||
manifest.Info.Id,
|
||||
dependency.PluginId,
|
||||
dependency.VersionConstraint,
|
||||
dependency.Optional));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check version constraint if specified
|
||||
if (!string.IsNullOrEmpty(dependency.VersionConstraint))
|
||||
{
|
||||
if (!SatisfiesVersionConstraint(availableVersion, dependency.VersionConstraint))
|
||||
{
|
||||
missing.Add(new MissingDependency(
|
||||
manifest.Info.Id,
|
||||
dependency.PluginId,
|
||||
dependency.VersionConstraint,
|
||||
dependency.Optional));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CircularDependencyError> ValidateDependencyGraph(IEnumerable<PluginManifest> manifests)
|
||||
{
|
||||
var graph = BuildDependencyGraph(manifests.ToList());
|
||||
return DetectCycles(graph);
|
||||
}
|
||||
|
||||
private static DependencyGraph BuildDependencyGraph(IReadOnlyList<PluginManifest> manifests)
|
||||
{
|
||||
var graph = new DependencyGraph();
|
||||
|
||||
// Add all nodes
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
graph.AddNode(manifest.Info.Id);
|
||||
}
|
||||
|
||||
// Add edges for dependencies
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
foreach (var dependency in manifest.Dependencies)
|
||||
{
|
||||
// Only add edge if the dependency exists in the graph
|
||||
if (graph.HasNode(dependency.PluginId))
|
||||
{
|
||||
// Edge goes from dependency to dependent (dependency must load first)
|
||||
graph.AddEdge(dependency.PluginId, manifest.Info.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CircularDependencyError> DetectCycles(DependencyGraph graph)
|
||||
{
|
||||
var cycles = new List<CircularDependencyError>();
|
||||
var visited = new HashSet<string>();
|
||||
var recursionStack = new HashSet<string>();
|
||||
var path = new List<string>();
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!visited.Contains(node))
|
||||
{
|
||||
DetectCyclesDfs(node, graph, visited, recursionStack, path, cycles);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
private static void DetectCyclesDfs(
|
||||
string node,
|
||||
DependencyGraph graph,
|
||||
HashSet<string> visited,
|
||||
HashSet<string> recursionStack,
|
||||
List<string> path,
|
||||
List<CircularDependencyError> cycles)
|
||||
{
|
||||
visited.Add(node);
|
||||
recursionStack.Add(node);
|
||||
path.Add(node);
|
||||
|
||||
foreach (var neighbor in graph.GetDependents(node))
|
||||
{
|
||||
if (!visited.Contains(neighbor))
|
||||
{
|
||||
DetectCyclesDfs(neighbor, graph, visited, recursionStack, path, cycles);
|
||||
}
|
||||
else if (recursionStack.Contains(neighbor))
|
||||
{
|
||||
// Found a cycle - extract the cycle from the path
|
||||
var cycleStart = path.IndexOf(neighbor);
|
||||
var cycle = path.Skip(cycleStart).ToList();
|
||||
cycles.Add(new CircularDependencyError(cycle));
|
||||
}
|
||||
}
|
||||
|
||||
path.RemoveAt(path.Count - 1);
|
||||
recursionStack.Remove(node);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TopologicalSort(DependencyGraph graph)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var visited = new HashSet<string>();
|
||||
var tempVisited = new HashSet<string>();
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!visited.Contains(node))
|
||||
{
|
||||
TopologicalSortVisit(node, graph, visited, tempVisited, result);
|
||||
}
|
||||
}
|
||||
|
||||
result.Reverse(); // Reverse to get correct order
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TopologicalSortVisit(
|
||||
string node,
|
||||
DependencyGraph graph,
|
||||
HashSet<string> visited,
|
||||
HashSet<string> tempVisited,
|
||||
List<string> result)
|
||||
{
|
||||
if (tempVisited.Contains(node))
|
||||
return; // Cycle detected, but we handle this separately
|
||||
|
||||
if (visited.Contains(node))
|
||||
return;
|
||||
|
||||
tempVisited.Add(node);
|
||||
|
||||
foreach (var neighbor in graph.GetDependents(node))
|
||||
{
|
||||
TopologicalSortVisit(neighbor, graph, visited, tempVisited, result);
|
||||
}
|
||||
|
||||
tempVisited.Remove(node);
|
||||
visited.Add(node);
|
||||
result.Add(node);
|
||||
}
|
||||
|
||||
private static bool SatisfiesVersionConstraint(string version, string constraint)
|
||||
{
|
||||
// Simple version constraint parsing
|
||||
// Supports: >=1.0.0, >1.0.0, =1.0.0, <2.0.0, <=2.0.0, ~1.0.0 (pessimistic), ^1.0.0 (compatible)
|
||||
|
||||
if (string.IsNullOrEmpty(constraint))
|
||||
return true;
|
||||
|
||||
constraint = constraint.Trim();
|
||||
|
||||
if (!Version.TryParse(version.TrimStart('v', 'V'), out var actualVersion))
|
||||
return false;
|
||||
|
||||
// Handle operators
|
||||
if (constraint.StartsWith(">="))
|
||||
{
|
||||
var required = ParseVersion(constraint[2..]);
|
||||
return required != null && actualVersion >= required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("<="))
|
||||
{
|
||||
var required = ParseVersion(constraint[2..]);
|
||||
return required != null && actualVersion <= required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith(">"))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion > required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("<"))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion < required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("="))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion == required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("~"))
|
||||
{
|
||||
// Pessimistic constraint (allows patch updates)
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
if (required == null)
|
||||
return false;
|
||||
|
||||
return actualVersion >= required &&
|
||||
actualVersion.Major == required.Major &&
|
||||
actualVersion.Minor == required.Minor;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("^"))
|
||||
{
|
||||
// Compatible with constraint (allows minor updates)
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
if (required == null)
|
||||
return false;
|
||||
|
||||
return actualVersion >= required && actualVersion.Major == required.Major;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
var exact = ParseVersion(constraint);
|
||||
return exact != null && actualVersion == exact;
|
||||
}
|
||||
|
||||
private static Version? ParseVersion(string versionString)
|
||||
{
|
||||
versionString = versionString.Trim().TrimStart('v', 'V');
|
||||
|
||||
// Handle versions with only major.minor
|
||||
var parts = versionString.Split('.');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
versionString = $"{versionString}.0";
|
||||
}
|
||||
|
||||
return Version.TryParse(versionString, out var version) ? version : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Combines multiple plugin discovery sources into a single discovery interface.
|
||||
/// </summary>
|
||||
public sealed class CompositePluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly IReadOnlyList<IPluginDiscovery> _discoverers;
|
||||
private readonly ILogger<CompositePluginDiscovery> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new composite plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="discoverers">The list of discovery sources.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public CompositePluginDiscovery(
|
||||
IEnumerable<IPluginDiscovery> discoverers,
|
||||
ILogger<CompositePluginDiscovery> logger)
|
||||
{
|
||||
_discoverers = discoverers.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new composite plugin discovery with the default discoverers.
|
||||
/// </summary>
|
||||
/// <param name="fileSystemDiscovery">Filesystem discovery.</param>
|
||||
/// <param name="embeddedDiscovery">Embedded discovery.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public CompositePluginDiscovery(
|
||||
FileSystemPluginDiscovery fileSystemDiscovery,
|
||||
EmbeddedPluginDiscovery embeddedDiscovery,
|
||||
ILogger<CompositePluginDiscovery> logger)
|
||||
: this([fileSystemDiscovery, embeddedDiscovery], logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allManifests = new List<PluginManifest>();
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var searchPathsList = searchPaths.ToList();
|
||||
|
||||
foreach (var discoverer in _discoverers)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var manifests = await discoverer.DiscoverAsync(searchPathsList, ct);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
// Deduplicate by plugin ID
|
||||
if (seenIds.Add(manifest.Info.Id))
|
||||
{
|
||||
allManifests.Add(manifest);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Duplicate plugin ID {PluginId} found, using first occurrence",
|
||||
manifest.Info.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Discovery failed for {Discoverer}", discoverer.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} unique plugins from {SourceCount} sources",
|
||||
allManifests.Count, _discoverers.Count);
|
||||
|
||||
return allManifests;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
// Find the appropriate discoverer for this source type
|
||||
IPluginDiscovery? discoverer = source.Type switch
|
||||
{
|
||||
PluginSourceType.FileSystem => _discoverers.OfType<FileSystemPluginDiscovery>().FirstOrDefault(),
|
||||
PluginSourceType.Embedded => _discoverers.OfType<EmbeddedPluginDiscovery>().FirstOrDefault(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (discoverer == null)
|
||||
{
|
||||
throw new NotSupportedException($"No discoverer available for source type: {source.Type}");
|
||||
}
|
||||
|
||||
return await discoverer.DiscoverSingleAsync(source, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Attributes;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers plugins embedded in loaded assemblies.
|
||||
/// </summary>
|
||||
public sealed class EmbeddedPluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly ILogger<EmbeddedPluginDiscovery> _logger;
|
||||
private readonly IReadOnlyList<Assembly> _assemblies;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new embedded plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="assemblies">Optional list of assemblies to scan. If null, scans all loaded assemblies.</param>
|
||||
public EmbeddedPluginDiscovery(
|
||||
ILogger<EmbeddedPluginDiscovery> logger,
|
||||
IEnumerable<Assembly>? assemblies = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_assemblies = assemblies?.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// searchPaths are ignored for embedded discovery
|
||||
// We only scan the provided assemblies or the application assemblies
|
||||
var manifests = new List<PluginManifest>();
|
||||
|
||||
var assembliesToScan = _assemblies.Count > 0
|
||||
? _assemblies
|
||||
: GetApplicationAssemblies();
|
||||
|
||||
foreach (var assembly in assembliesToScan)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var pluginTypes = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && typeof(IPlugin).IsAssignableFrom(t))
|
||||
.ToList();
|
||||
|
||||
foreach (var pluginType in pluginTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = CreateManifestFromType(pluginType);
|
||||
if (manifest != null)
|
||||
{
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug(
|
||||
"Discovered embedded plugin {PluginId} in assembly {Assembly}",
|
||||
manifest.Info.Id, assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to create manifest for type {Type} in assembly {Assembly}",
|
||||
pluginType.FullName, assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load types from assembly {Assembly}",
|
||||
assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
if (source.Type != PluginSourceType.Embedded)
|
||||
throw new ArgumentException($"Unsupported source type: {source.Type}", nameof(source));
|
||||
|
||||
// Location should be the fully qualified type name
|
||||
var typeName = source.Location;
|
||||
|
||||
foreach (var assembly in GetApplicationAssemblies())
|
||||
{
|
||||
var type = assembly.GetType(typeName);
|
||||
if (type != null && typeof(IPlugin).IsAssignableFrom(type))
|
||||
{
|
||||
var manifest = CreateManifestFromType(type);
|
||||
if (manifest != null)
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Embedded plugin type not found: {typeName}");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Assembly> GetApplicationAssemblies()
|
||||
{
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
if (entryAssembly == null)
|
||||
return AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
var referencedNames = entryAssembly.GetReferencedAssemblies();
|
||||
var assemblies = new List<Assembly> { entryAssembly };
|
||||
|
||||
foreach (var name in referencedNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
assemblies.Add(Assembly.Load(name));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip assemblies that can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
return assemblies;
|
||||
}
|
||||
|
||||
private static PluginManifest? CreateManifestFromType(Type pluginType)
|
||||
{
|
||||
var attribute = pluginType.GetCustomAttribute<PluginAttribute>();
|
||||
if (attribute == null)
|
||||
return null;
|
||||
|
||||
return new PluginManifest
|
||||
{
|
||||
Info = new PluginInfo(
|
||||
Id: attribute.Id,
|
||||
Name: attribute.Name ?? pluginType.Name,
|
||||
Version: attribute.Version ?? "1.0.0",
|
||||
Vendor: attribute.Vendor ?? "Unknown",
|
||||
Description: attribute.Description),
|
||||
EntryPoint = pluginType.FullName!,
|
||||
AssemblyPath = pluginType.Assembly.Location,
|
||||
Capabilities = [],
|
||||
Dependencies = [],
|
||||
Permissions = [],
|
||||
Tags = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers plugins from the filesystem by scanning for manifest files.
|
||||
/// </summary>
|
||||
public sealed class FileSystemPluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly ILogger<FileSystemPluginDiscovery> _logger;
|
||||
private static readonly string[] ManifestFileNames = ["plugin.yaml", "plugin.yml", "plugin.json"];
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new filesystem plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public FileSystemPluginDiscovery(ILogger<FileSystemPluginDiscovery> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var manifests = new List<PluginManifest>();
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
if (!Directory.Exists(searchPath))
|
||||
{
|
||||
_logger.LogWarning("Plugin search path does not exist: {Path}", searchPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Searching for plugins in {Path}", searchPath);
|
||||
|
||||
// Look for plugin directories (contain plugin.yaml/plugin.json)
|
||||
foreach (var dir in Directory.EnumerateDirectories(searchPath))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var manifestPath = FindManifestFile(dir);
|
||||
if (manifestPath == null)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = await ParseManifestAsync(manifestPath, ct);
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug("Discovered plugin {PluginId} at {Path}", manifest.Info.Id, dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse manifest at {Path}", manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if there's a manifest directly in the search path
|
||||
var directManifest = FindManifestFile(searchPath);
|
||||
if (directManifest != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = await ParseManifestAsync(directManifest, ct);
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug("Discovered plugin {PluginId} at {Path}", manifest.Info.Id, searchPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse manifest at {Path}", directManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
if (source.Type != PluginSourceType.FileSystem)
|
||||
throw new ArgumentException($"Unsupported source type: {source.Type}", nameof(source));
|
||||
|
||||
var manifestPath = FindManifestFile(source.Location)
|
||||
?? throw new FileNotFoundException($"No plugin manifest found in {source.Location}");
|
||||
|
||||
return await ParseManifestAsync(manifestPath, ct);
|
||||
}
|
||||
|
||||
private static string? FindManifestFile(string directory)
|
||||
{
|
||||
foreach (var fileName in ManifestFileNames)
|
||||
{
|
||||
var path = Path.Combine(directory, fileName);
|
||||
if (File.Exists(path))
|
||||
return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<PluginManifest> ParseManifestAsync(string manifestPath, CancellationToken ct)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var extension = Path.GetExtension(manifestPath).ToLowerInvariant();
|
||||
|
||||
var manifest = extension switch
|
||||
{
|
||||
".yaml" or ".yml" => ParseYamlManifest(content),
|
||||
".json" => ParseJsonManifest(content),
|
||||
_ => throw new InvalidOperationException($"Unknown manifest format: {extension}")
|
||||
};
|
||||
|
||||
manifest.ManifestPath = manifestPath;
|
||||
|
||||
// Resolve assembly path relative to manifest location
|
||||
if (manifest.AssemblyPath != null && !Path.IsPathRooted(manifest.AssemblyPath))
|
||||
{
|
||||
var manifestDir = Path.GetDirectoryName(manifestPath)!;
|
||||
var absolutePath = Path.GetFullPath(Path.Combine(manifestDir, manifest.AssemblyPath));
|
||||
|
||||
// Create a new manifest with the resolved path
|
||||
manifest = new PluginManifest
|
||||
{
|
||||
Info = manifest.Info,
|
||||
EntryPoint = manifest.EntryPoint,
|
||||
AssemblyPath = absolutePath,
|
||||
MinPlatformVersion = manifest.MinPlatformVersion,
|
||||
MaxPlatformVersion = manifest.MaxPlatformVersion,
|
||||
Capabilities = manifest.Capabilities,
|
||||
ConfigSchema = manifest.ConfigSchema,
|
||||
DefaultConfig = manifest.DefaultConfig,
|
||||
Dependencies = manifest.Dependencies,
|
||||
Resources = manifest.Resources,
|
||||
Permissions = manifest.Permissions,
|
||||
Tags = manifest.Tags,
|
||||
Metadata = manifest.Metadata,
|
||||
ManifestPath = manifestPath
|
||||
};
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static PluginManifest ParseYamlManifest(string content)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
var dto = deserializer.Deserialize<ManifestDto>(content);
|
||||
return dto.ToManifest();
|
||||
}
|
||||
|
||||
private static PluginManifest ParseJsonManifest(string content)
|
||||
{
|
||||
return JsonSerializer.Deserialize<PluginManifest>(content, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse manifest JSON");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for YAML deserialization.
|
||||
/// </summary>
|
||||
private sealed class ManifestDto
|
||||
{
|
||||
public required InfoDto Info { get; set; }
|
||||
public required string EntryPoint { get; set; }
|
||||
public string? AssemblyPath { get; set; }
|
||||
public string? MinPlatformVersion { get; set; }
|
||||
public string? MaxPlatformVersion { get; set; }
|
||||
public List<CapabilityDto>? Capabilities { get; set; }
|
||||
public List<DependencyDto>? Dependencies { get; set; }
|
||||
public ResourcesDto? Resources { get; set; }
|
||||
public List<string>? Permissions { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public PluginManifest ToManifest()
|
||||
{
|
||||
return new PluginManifest
|
||||
{
|
||||
Info = Info.ToPluginInfo(),
|
||||
EntryPoint = EntryPoint,
|
||||
AssemblyPath = AssemblyPath,
|
||||
MinPlatformVersion = MinPlatformVersion,
|
||||
MaxPlatformVersion = MaxPlatformVersion,
|
||||
Capabilities = Capabilities?.Select(c => c.ToCapabilityDeclaration()).ToList() ?? [],
|
||||
Dependencies = Dependencies?.Select(d => d.ToPluginDependency()).ToList() ?? [],
|
||||
Resources = Resources?.ToResourceRequirements(),
|
||||
Permissions = Permissions ?? [],
|
||||
Tags = Tags ?? [],
|
||||
Metadata = Metadata
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class InfoDto
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Version { get; set; }
|
||||
public required string Vendor { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? License { get; set; }
|
||||
public string? Homepage { get; set; }
|
||||
public string? Repository { get; set; }
|
||||
|
||||
public Abstractions.PluginInfo ToPluginInfo()
|
||||
{
|
||||
return new Abstractions.PluginInfo(
|
||||
Id: Id,
|
||||
Name: Name,
|
||||
Version: Version,
|
||||
Vendor: Vendor,
|
||||
Description: Description,
|
||||
LicenseId: License,
|
||||
ProjectUrl: Homepage ?? Repository);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CapabilityDto
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public CapabilityDeclaration ToCapabilityDeclaration()
|
||||
{
|
||||
return new CapabilityDeclaration
|
||||
{
|
||||
Type = Type,
|
||||
Id = Id,
|
||||
DisplayName = DisplayName,
|
||||
Description = Description,
|
||||
Metadata = Metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DependencyDto
|
||||
{
|
||||
public required string PluginId { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public bool Optional { get; set; }
|
||||
|
||||
public PluginDependency ToPluginDependency()
|
||||
{
|
||||
return new PluginDependency(PluginId, Version, Optional);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ResourcesDto
|
||||
{
|
||||
public int MinMemoryMb { get; set; } = 64;
|
||||
public int MaxMemoryMb { get; set; } = 512;
|
||||
public string? CpuLimit { get; set; }
|
||||
public int DiskMb { get; set; } = 100;
|
||||
public bool NetworkRequired { get; set; }
|
||||
public bool GpuRequired { get; set; }
|
||||
|
||||
public ResourceRequirements ToResourceRequirements()
|
||||
{
|
||||
return new ResourceRequirements
|
||||
{
|
||||
MinMemoryMb = MinMemoryMb,
|
||||
MaxMemoryMb = MaxMemoryMb,
|
||||
CpuLimit = CpuLimit,
|
||||
DiskMb = DiskMb,
|
||||
NetworkRequired = NetworkRequired,
|
||||
GpuRequired = GpuRequired
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for discovering plugins from various sources.
|
||||
/// </summary>
|
||||
public interface IPluginDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Discover all plugins from search paths.
|
||||
/// </summary>
|
||||
/// <param name="searchPaths">Paths to search for plugins.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of discovered plugin manifests.</returns>
|
||||
Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Discover a single plugin from a specific source.
|
||||
/// </summary>
|
||||
/// <param name="source">The plugin source.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The discovered plugin manifest.</returns>
|
||||
Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin discovery operation.
|
||||
/// </summary>
|
||||
/// <param name="Manifests">Discovered plugin manifests.</param>
|
||||
/// <param name="Errors">Errors encountered during discovery.</param>
|
||||
public sealed record PluginDiscoveryResult(
|
||||
IReadOnlyList<PluginManifest> Manifests,
|
||||
IReadOnlyList<PluginDiscoveryError> Errors)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful discovery result with no errors.
|
||||
/// </summary>
|
||||
public static PluginDiscoveryResult Success(IReadOnlyList<PluginManifest> manifests) =>
|
||||
new(manifests, []);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty discovery result.
|
||||
/// </summary>
|
||||
public static PluginDiscoveryResult Empty() =>
|
||||
new([], []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error encountered during plugin discovery.
|
||||
/// </summary>
|
||||
/// <param name="Location">Location where error occurred.</param>
|
||||
/// <param name="Message">Error message.</param>
|
||||
/// <param name="Exception">Optional exception.</param>
|
||||
public sealed record PluginDiscoveryError(
|
||||
string Location,
|
||||
string Message,
|
||||
Exception? Exception = null);
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Host.Context;
|
||||
using StellaOps.Plugin.Host.Dependencies;
|
||||
using StellaOps.Plugin.Host.Discovery;
|
||||
using StellaOps.Plugin.Host.Health;
|
||||
using StellaOps.Plugin.Host.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Loading;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering plugin host services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the plugin host and related services 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 AddPluginHost(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind options
|
||||
services.AddOptions<PluginHostOptions>()
|
||||
.Bind(configuration.GetSection(PluginHostOptions.SectionName));
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Discovery services
|
||||
services.AddSingleton<FileSystemPluginDiscovery>();
|
||||
services.AddSingleton<EmbeddedPluginDiscovery>();
|
||||
services.AddSingleton<IPluginDiscovery, CompositePluginDiscovery>();
|
||||
|
||||
// Loading services
|
||||
services.AddSingleton<IHostPluginLoader, AssemblyPluginLoader>();
|
||||
|
||||
// Lifecycle services
|
||||
services.AddSingleton<IPluginLifecycleManager, PluginLifecycleManager>();
|
||||
|
||||
// Health monitoring
|
||||
services.AddSingleton<IPluginHealthMonitor, PluginHealthMonitor>();
|
||||
|
||||
// Dependency resolution
|
||||
services.AddSingleton<IPluginDependencyResolver, PluginDependencyResolver>();
|
||||
|
||||
// Context factory
|
||||
services.AddSingleton<PluginContextFactory>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var options = sp.GetRequiredService<IOptions<PluginHostOptions>>().Value;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
return new PluginContextFactory(
|
||||
loggerFactory,
|
||||
options,
|
||||
sp,
|
||||
timeProvider);
|
||||
});
|
||||
|
||||
// Plugin host
|
||||
services.AddSingleton<IPluginHost, PluginHost>();
|
||||
|
||||
// Hosted service to start/stop plugin host
|
||||
services.AddHostedService<PluginHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin host with custom options configuration.
|
||||
/// </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 AddPluginHost(
|
||||
this IServiceCollection services,
|
||||
Action<PluginHostOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Discovery services
|
||||
services.AddSingleton<FileSystemPluginDiscovery>();
|
||||
services.AddSingleton<EmbeddedPluginDiscovery>();
|
||||
services.AddSingleton<IPluginDiscovery, CompositePluginDiscovery>();
|
||||
|
||||
// Loading services
|
||||
services.AddSingleton<IHostPluginLoader, AssemblyPluginLoader>();
|
||||
|
||||
// Lifecycle services
|
||||
services.AddSingleton<IPluginLifecycleManager, PluginLifecycleManager>();
|
||||
|
||||
// Health monitoring
|
||||
services.AddSingleton<IPluginHealthMonitor, PluginHealthMonitor>();
|
||||
|
||||
// Dependency resolution
|
||||
services.AddSingleton<IPluginDependencyResolver, PluginDependencyResolver>();
|
||||
|
||||
// Context factory
|
||||
services.AddSingleton<PluginContextFactory>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var options = sp.GetRequiredService<IOptions<PluginHostOptions>>().Value;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
return new PluginContextFactory(
|
||||
loggerFactory,
|
||||
options,
|
||||
sp,
|
||||
timeProvider);
|
||||
});
|
||||
|
||||
// Plugin host
|
||||
services.AddSingleton<IPluginHost, PluginHost>();
|
||||
|
||||
// Hosted service to start/stop plugin host
|
||||
services.AddHostedService<PluginHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the plugin host lifecycle.
|
||||
/// </summary>
|
||||
public sealed class PluginHostedService : IHostedService
|
||||
{
|
||||
private readonly IPluginHost _pluginHost;
|
||||
private readonly ILogger<PluginHostedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin hosted service.
|
||||
/// </summary>
|
||||
/// <param name="pluginHost">The plugin host.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public PluginHostedService(IPluginHost pluginHost, ILogger<PluginHostedService> logger)
|
||||
{
|
||||
_pluginHost = pluginHost;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Starting plugin host via hosted service...");
|
||||
await _pluginHost.StartAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping plugin host via hosted service...");
|
||||
await _pluginHost.StopAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Monitors the health of loaded plugins.
|
||||
/// </summary>
|
||||
public interface IPluginHealthMonitor : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Start the health monitoring loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop the health monitoring loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Register a plugin for health monitoring.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The loaded plugin.</param>
|
||||
void RegisterPlugin(LoadedPlugin plugin);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin from health monitoring.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
void UnregisterPlugin(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Perform an immediate health check on a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The health check result.</returns>
|
||||
Task<HealthCheckResult> CheckHealthAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current health status of a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The health status, or null if not registered.</returns>
|
||||
HealthStatus? GetHealthStatus(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get all plugin health statuses.
|
||||
/// </summary>
|
||||
/// <returns>Dictionary of plugin ID to health status.</returns>
|
||||
IReadOnlyDictionary<string, HealthStatus> GetAllHealthStatuses();
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin's health status changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginHealthChangedEventArgs>? HealthChanged;
|
||||
}
|
||||
253
src/Plugin/StellaOps.Plugin.Host/Health/PluginHealthMonitor.cs
Normal file
253
src/Plugin/StellaOps.Plugin.Host/Health/PluginHealthMonitor.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Monitors plugin health with periodic checks and status change notifications.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthMonitor : IPluginHealthMonitor
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginHealthState> _healthStates = new();
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly ILogger<PluginHealthMonitor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private Task? _monitorTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginHealthChangedEventArgs>? HealthChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin health monitor.
|
||||
/// </summary>
|
||||
/// <param name="options">Plugin host options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
public PluginHealthMonitor(
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<PluginHealthMonitor> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
|
||||
_logger.LogInformation("Plugin health monitor started with interval {Interval}",
|
||||
_options.HealthCheckInterval);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cts != null)
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
if (_monitorTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _monitorTask.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Plugin health monitor stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterPlugin(LoadedPlugin plugin)
|
||||
{
|
||||
var state = new PluginHealthState
|
||||
{
|
||||
Plugin = plugin,
|
||||
LastCheck = _timeProvider.GetUtcNow(),
|
||||
Status = HealthStatus.Healthy,
|
||||
ConsecutiveFailures = 0
|
||||
};
|
||||
|
||||
_healthStates[plugin.Info.Id] = state;
|
||||
_logger.LogDebug("Registered plugin {PluginId} for health monitoring", plugin.Info.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnregisterPlugin(string pluginId)
|
||||
{
|
||||
if (_healthStates.TryRemove(pluginId, out _))
|
||||
{
|
||||
_logger.LogDebug("Unregistered plugin {PluginId} from health monitoring", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_healthStates.TryGetValue(pluginId, out var state))
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Plugin not registered for health monitoring");
|
||||
}
|
||||
|
||||
return await PerformHealthCheckAsync(state, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HealthStatus? GetHealthStatus(string pluginId)
|
||||
{
|
||||
return _healthStates.TryGetValue(pluginId, out var state) ? state.Status : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, HealthStatus> GetAllHealthStatuses()
|
||||
{
|
||||
return _healthStates.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.Status);
|
||||
}
|
||||
|
||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var timer = new PeriodicTimer(_options.HealthCheckInterval);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await timer.WaitForNextTickAsync(ct);
|
||||
|
||||
// Check all registered plugins
|
||||
foreach (var kvp in _healthStates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var state = kvp.Value;
|
||||
var timeSinceLastCheck = _timeProvider.GetUtcNow() - state.LastCheck;
|
||||
|
||||
if (timeSinceLastCheck >= _options.HealthCheckInterval)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PerformHealthCheckAsync(state, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed for plugin {PluginId}", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health monitor loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HealthCheckResult> PerformHealthCheckAsync(PluginHealthState state, CancellationToken ct)
|
||||
{
|
||||
var plugin = state.Plugin;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_options.HealthCheckTimeout);
|
||||
|
||||
var result = await plugin.Instance.HealthCheckAsync(timeoutCts.Token);
|
||||
stopwatch.Stop();
|
||||
|
||||
result = result with { Duration = stopwatch.Elapsed };
|
||||
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Timeout
|
||||
var result = HealthCheckResult.Unhealthy("Health check timed out")
|
||||
.WithDuration(stopwatch.Elapsed);
|
||||
|
||||
state.ConsecutiveFailures++;
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
var result = HealthCheckResult.Unhealthy(ex)
|
||||
.WithDuration(stopwatch.Elapsed);
|
||||
|
||||
state.ConsecutiveFailures++;
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHealthState(PluginHealthState state, HealthCheckResult result)
|
||||
{
|
||||
var oldStatus = state.Status;
|
||||
var newStatus = result.Status;
|
||||
|
||||
state.Status = newStatus;
|
||||
state.LastCheck = _timeProvider.GetUtcNow();
|
||||
state.LastResult = result;
|
||||
|
||||
if (newStatus == HealthStatus.Healthy)
|
||||
{
|
||||
state.ConsecutiveFailures = 0;
|
||||
}
|
||||
|
||||
// Raise event if status changed
|
||||
if (oldStatus != newStatus)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Plugin {PluginId} health changed from {OldStatus} to {NewStatus}",
|
||||
state.Plugin.Info.Id, oldStatus, newStatus);
|
||||
|
||||
HealthChanged?.Invoke(this, new PluginHealthChangedEventArgs
|
||||
{
|
||||
PluginId = state.Plugin.Info.Id,
|
||||
OldStatus = oldStatus,
|
||||
NewStatus = newStatus,
|
||||
CheckResult = result
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None);
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal class to track plugin health state.
|
||||
/// </summary>
|
||||
private sealed class PluginHealthState
|
||||
{
|
||||
public required LoadedPlugin Plugin { get; init; }
|
||||
public DateTimeOffset LastCheck { get; set; }
|
||||
public HealthStatus Status { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public HealthCheckResult? LastResult { get; set; }
|
||||
}
|
||||
}
|
||||
174
src/Plugin/StellaOps.Plugin.Host/IPluginHost.cs
Normal file
174
src/Plugin/StellaOps.Plugin.Host/IPluginHost.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Central coordinator for plugin lifecycle management.
|
||||
/// Handles discovery, loading, initialization, and shutdown of plugins.
|
||||
/// </summary>
|
||||
public interface IPluginHost : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// All currently loaded plugins.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, LoadedPlugin> Plugins { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Discover and load all plugins from configured sources.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gracefully stop all plugins and release resources.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Load a specific plugin from a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The plugin source.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded plugin.</returns>
|
||||
Task<LoadedPlugin> LoadPluginAsync(PluginSource source, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Unload a specific plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to unload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnloadPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reload a plugin (unload then load).
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to reload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reloaded plugin.</returns>
|
||||
Task<LoadedPlugin> ReloadPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins that implement a specific capability interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <returns>Enumeration of plugins with the capability.</returns>
|
||||
IEnumerable<T> GetPluginsWithCapability<T>() where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific plugin by ID.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The plugin, or null if not found.</returns>
|
||||
LoadedPlugin? GetPlugin(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get a plugin capability instance.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The capability, or null if not found or not provided.</returns>
|
||||
T? GetCapability<T>(string pluginId) where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin state changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginStateChangedEventArgs>? PluginStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin health status changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginHealthChangedEventArgs>? PluginHealthChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a source from which a plugin can be loaded.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of source.</param>
|
||||
/// <param name="Location">The source location (path, URL, etc.).</param>
|
||||
/// <param name="Metadata">Optional metadata about the source.</param>
|
||||
public sealed record PluginSource(
|
||||
PluginSourceType Type,
|
||||
string Location,
|
||||
IReadOnlyDictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Types of plugin sources.
|
||||
/// </summary>
|
||||
public enum PluginSourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin loaded from filesystem.
|
||||
/// </summary>
|
||||
FileSystem,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin embedded in the application.
|
||||
/// </summary>
|
||||
Embedded,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin loaded from a remote URL.
|
||||
/// </summary>
|
||||
Remote,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin definition stored in database.
|
||||
/// </summary>
|
||||
Database
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for plugin state changes.
|
||||
/// </summary>
|
||||
public sealed class PluginStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the plugin whose state changed.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState OldState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState NewState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the state change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for plugin health changes.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the plugin whose health changed.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous health status.
|
||||
/// </summary>
|
||||
public required HealthStatus OldStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New health status.
|
||||
/// </summary>
|
||||
public required HealthStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The health check result that triggered the change.
|
||||
/// </summary>
|
||||
public HealthCheckResult? CheckResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Manages plugin lifecycle state transitions.
|
||||
/// </summary>
|
||||
public interface IPluginLifecycleManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a plugin for lifecycle management.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="initialState">Initial state.</param>
|
||||
void Register(string pluginId, PluginLifecycleState initialState = PluginLifecycleState.Discovered);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin from lifecycle management.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
void Unregister(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current state of a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The current state, or null if not registered.</returns>
|
||||
PluginLifecycleState? GetState(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to transition a plugin to a new state.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="targetState">The target state.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if transition succeeded.</returns>
|
||||
Task<bool> TransitionAsync(string pluginId, PluginLifecycleState targetState, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a transition is valid.
|
||||
/// </summary>
|
||||
/// <param name="fromState">Current state.</param>
|
||||
/// <param name="toState">Target state.</param>
|
||||
/// <returns>True if the transition is allowed.</returns>
|
||||
bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin state changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginStateTransitionEventArgs>? StateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for state transitions.
|
||||
/// </summary>
|
||||
public sealed class PluginStateTransitionEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin ID.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The previous state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState FromState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState ToState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the transition occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset TransitionedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the transition.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Manages plugin lifecycle state transitions with validation and event notification.
|
||||
/// </summary>
|
||||
public sealed class PluginLifecycleManager : IPluginLifecycleManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginLifecycleInfo> _pluginStates = new();
|
||||
private readonly ILogger<PluginLifecycleManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginStateTransitionEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle manager.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
public PluginLifecycleManager(
|
||||
ILogger<PluginLifecycleManager> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(string pluginId, PluginLifecycleState initialState = PluginLifecycleState.Discovered)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var info = new PluginLifecycleInfo(pluginId, initialState, now);
|
||||
|
||||
if (!_pluginStates.TryAdd(pluginId, info))
|
||||
{
|
||||
_logger.LogWarning("Plugin {PluginId} is already registered", pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Registered plugin {PluginId} with initial state {State}", pluginId, initialState);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unregister(string pluginId)
|
||||
{
|
||||
if (_pluginStates.TryRemove(pluginId, out _))
|
||||
{
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState? GetState(string pluginId)
|
||||
{
|
||||
return _pluginStates.TryGetValue(pluginId, out var info) ? info.CurrentState : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TransitionAsync(string pluginId, PluginLifecycleState targetState, CancellationToken ct)
|
||||
{
|
||||
if (!_pluginStates.TryGetValue(pluginId, out var info))
|
||||
{
|
||||
_logger.LogWarning("Cannot transition unregistered plugin {PluginId}", pluginId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
lock (info.Lock)
|
||||
{
|
||||
var currentState = info.CurrentState;
|
||||
|
||||
if (currentState == targetState)
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} is already in state {State}", pluginId, targetState);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
if (!IsValidTransition(currentState, targetState))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid state transition for plugin {PluginId}: {FromState} -> {ToState}",
|
||||
pluginId, currentState, targetState);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var transition = new PluginStateTransition(currentState, targetState, now);
|
||||
|
||||
info.CurrentState = targetState;
|
||||
info.LastTransitionedAt = now;
|
||||
info.TransitionHistory.Add(transition);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Plugin {PluginId} transitioned from {FromState} to {ToState}",
|
||||
pluginId, currentState, targetState);
|
||||
|
||||
// Raise event
|
||||
StateChanged?.Invoke(this, new PluginStateTransitionEventArgs
|
||||
{
|
||||
PluginId = pluginId,
|
||||
FromState = currentState,
|
||||
ToState = targetState,
|
||||
TransitionedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState)
|
||||
{
|
||||
return PluginStateMachine.IsValidTransition(fromState, toState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition history for a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The transition history, or empty if not found.</returns>
|
||||
public IReadOnlyList<PluginStateTransition> GetTransitionHistory(string pluginId)
|
||||
{
|
||||
return _pluginStates.TryGetValue(pluginId, out var info)
|
||||
? info.TransitionHistory.ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered plugin IDs.
|
||||
/// </summary>
|
||||
/// <returns>Set of plugin IDs.</returns>
|
||||
public IReadOnlySet<string> GetRegisteredPlugins()
|
||||
{
|
||||
return _pluginStates.Keys.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets plugins in a specific state.
|
||||
/// </summary>
|
||||
/// <param name="state">The state to filter by.</param>
|
||||
/// <returns>List of plugin IDs in that state.</returns>
|
||||
public IReadOnlyList<string> GetPluginsInState(PluginLifecycleState state)
|
||||
{
|
||||
return _pluginStates
|
||||
.Where(kvp => kvp.Value.CurrentState == state)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal class to track plugin lifecycle state.
|
||||
/// </summary>
|
||||
private sealed class PluginLifecycleInfo
|
||||
{
|
||||
public string PluginId { get; }
|
||||
public PluginLifecycleState CurrentState { get; set; }
|
||||
public DateTimeOffset RegisteredAt { get; }
|
||||
public DateTimeOffset LastTransitionedAt { get; set; }
|
||||
public List<PluginStateTransition> TransitionHistory { get; } = [];
|
||||
public object Lock { get; } = new();
|
||||
|
||||
public PluginLifecycleInfo(string pluginId, PluginLifecycleState initialState, DateTimeOffset registeredAt)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
CurrentState = initialState;
|
||||
RegisteredAt = registeredAt;
|
||||
LastTransitionedAt = registeredAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/Plugin/StellaOps.Plugin.Host/Lifecycle/PluginStateMachine.cs
Normal file
150
src/Plugin/StellaOps.Plugin.Host/Lifecycle/PluginStateMachine.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the valid state transitions for plugin lifecycle.
|
||||
/// </summary>
|
||||
public static class PluginStateMachine
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines all valid state transitions.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<PluginLifecycleState, HashSet<PluginLifecycleState>> ValidTransitions = new()
|
||||
{
|
||||
// Discovered can transition to Loading or Failed
|
||||
[PluginLifecycleState.Discovered] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Loading can transition to Initializing or Failed
|
||||
[PluginLifecycleState.Loading] =
|
||||
[
|
||||
PluginLifecycleState.Initializing,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Initializing can transition to Active, Degraded, or Failed
|
||||
[PluginLifecycleState.Initializing] =
|
||||
[
|
||||
PluginLifecycleState.Active,
|
||||
PluginLifecycleState.Degraded,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Active can transition to Degraded, Stopping, or Failed
|
||||
[PluginLifecycleState.Active] =
|
||||
[
|
||||
PluginLifecycleState.Degraded,
|
||||
PluginLifecycleState.Stopping,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Degraded can transition to Active, Stopping, or Failed
|
||||
[PluginLifecycleState.Degraded] =
|
||||
[
|
||||
PluginLifecycleState.Active,
|
||||
PluginLifecycleState.Stopping,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Stopping can transition to Stopped, Unloading, or Failed
|
||||
[PluginLifecycleState.Stopping] =
|
||||
[
|
||||
PluginLifecycleState.Stopped,
|
||||
PluginLifecycleState.Unloading,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Stopped can transition to Loading (reload) or Unloading
|
||||
[PluginLifecycleState.Stopped] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Unloading
|
||||
],
|
||||
|
||||
// Failed can transition to Loading (retry) or Unloading
|
||||
[PluginLifecycleState.Failed] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Unloading
|
||||
],
|
||||
|
||||
// Unloading is terminal - no transitions allowed
|
||||
[PluginLifecycleState.Unloading] = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Check if a state transition is valid.
|
||||
/// </summary>
|
||||
/// <param name="fromState">Current state.</param>
|
||||
/// <param name="toState">Target state.</param>
|
||||
/// <returns>True if the transition is allowed.</returns>
|
||||
public static bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState)
|
||||
{
|
||||
return ValidTransitions.TryGetValue(fromState, out var validTargets)
|
||||
&& validTargets.Contains(toState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all valid target states from a given state.
|
||||
/// </summary>
|
||||
/// <param name="fromState">The current state.</param>
|
||||
/// <returns>Set of valid target states.</returns>
|
||||
public static IReadOnlySet<PluginLifecycleState> GetValidTargets(PluginLifecycleState fromState)
|
||||
{
|
||||
return ValidTransitions.TryGetValue(fromState, out var validTargets)
|
||||
? validTargets
|
||||
: new HashSet<PluginLifecycleState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is operational (can handle requests).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if operational.</returns>
|
||||
public static bool IsOperational(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Active || state == PluginLifecycleState.Degraded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is terminal (plugin is done).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if terminal.</returns>
|
||||
public static bool IsTerminal(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Stopped
|
||||
|| state == PluginLifecycleState.Failed
|
||||
|| state == PluginLifecycleState.Unloading;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is transitional (in progress).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if transitional.</returns>
|
||||
public static bool IsTransitional(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Loading
|
||||
|| state == PluginLifecycleState.Initializing
|
||||
|| state == PluginLifecycleState.Stopping
|
||||
|| state == PluginLifecycleState.Unloading;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a state transition record.
|
||||
/// </summary>
|
||||
/// <param name="FromState">Previous state.</param>
|
||||
/// <param name="ToState">New state.</param>
|
||||
/// <param name="TransitionedAt">When the transition occurred.</param>
|
||||
/// <param name="Reason">Optional reason for the transition.</param>
|
||||
public sealed record PluginStateTransition(
|
||||
PluginLifecycleState FromState,
|
||||
PluginLifecycleState ToState,
|
||||
DateTimeOffset TransitionedAt,
|
||||
string? Reason = null);
|
||||
213
src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs
Normal file
213
src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins from assemblies with optional isolation using AssemblyLoadContext.
|
||||
/// </summary>
|
||||
public sealed class AssemblyPluginLoader : IHostPluginLoader
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginLoadContextReference> _loadContexts = new();
|
||||
private readonly ILogger<AssemblyPluginLoader> _logger;
|
||||
private readonly PluginHostOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new assembly plugin loader.
|
||||
/// </summary>
|
||||
/// <param name="options">Plugin host options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public AssemblyPluginLoader(
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<AssemblyPluginLoader> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginAssemblyLoadResult> LoadAsync(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var assemblyPath = ResolveAssemblyPath(manifest);
|
||||
var pluginId = manifest.Info.Id;
|
||||
|
||||
_logger.LogDebug("Loading plugin assembly from {Path}", assemblyPath);
|
||||
|
||||
// Determine if we should isolate this plugin
|
||||
var shouldIsolate = _options.EnableAssemblyIsolation && trustLevel != PluginTrustLevel.BuiltIn;
|
||||
|
||||
PluginAssemblyLoadContext? loadContext = null;
|
||||
Assembly assembly;
|
||||
|
||||
if (shouldIsolate)
|
||||
{
|
||||
// Create isolated load context
|
||||
loadContext = new PluginAssemblyLoadContext(
|
||||
pluginId,
|
||||
assemblyPath,
|
||||
isCollectible: true);
|
||||
|
||||
_loadContexts[pluginId] = new PluginLoadContextReference(loadContext, pluginId);
|
||||
|
||||
try
|
||||
{
|
||||
assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Cleanup on failure
|
||||
_loadContexts.TryRemove(pluginId, out _);
|
||||
loadContext.Unload();
|
||||
throw new PluginLoadException(pluginId, $"Failed to load assembly: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Load into default context (for built-in plugins)
|
||||
assembly = Assembly.LoadFrom(assemblyPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Find the entry point type
|
||||
var entryPointType = assembly.GetType(manifest.EntryPoint)
|
||||
?? throw new PluginLoadException(pluginId, $"Entry point type '{manifest.EntryPoint}' not found");
|
||||
|
||||
// Verify it implements IPlugin
|
||||
if (!typeof(IPlugin).IsAssignableFrom(entryPointType))
|
||||
throw new PluginLoadException(pluginId, $"Entry point type '{manifest.EntryPoint}' does not implement IPlugin");
|
||||
|
||||
// Create instance
|
||||
var instance = Activator.CreateInstance(entryPointType) as IPlugin
|
||||
?? throw new PluginLoadException(pluginId, $"Failed to create instance of '{manifest.EntryPoint}'");
|
||||
|
||||
_logger.LogDebug("Loaded plugin {PluginId} (isolated: {Isolated})", pluginId, shouldIsolate);
|
||||
|
||||
return Task.FromResult(new PluginAssemblyLoadResult(instance, assembly, loadContext));
|
||||
}
|
||||
catch (Exception ex) when (ex is not PluginLoadException)
|
||||
{
|
||||
// Cleanup on failure
|
||||
if (loadContext != null)
|
||||
{
|
||||
_loadContexts.TryRemove(pluginId, out _);
|
||||
loadContext.Unload();
|
||||
}
|
||||
throw new PluginLoadException(pluginId, $"Failed to instantiate plugin: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginAssemblyLoadResult> LoadAsync<T>(
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct) where T : class, IPlugin, new()
|
||||
{
|
||||
var instance = new T();
|
||||
var assembly = typeof(T).Assembly;
|
||||
|
||||
// Embedded plugins are loaded in the default context
|
||||
return Task.FromResult(new PluginAssemblyLoadResult(instance, assembly, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnloadAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_loadContexts.TryRemove(pluginId, out var contextRef))
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} was not isolated, no unload needed", pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
contextRef.Unload();
|
||||
|
||||
// Wait for GC to collect the assemblies
|
||||
const int maxAttempts = 10;
|
||||
const int delayMs = 100;
|
||||
|
||||
for (int i = 0; i < maxAttempts && !contextRef.IsCollected; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
if (!contextRef.IsCollected)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Plugin {PluginId} load context still alive after unload - possible memory leak",
|
||||
pluginId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} unloaded and collected", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLoaded(string pluginId)
|
||||
{
|
||||
return _loadContexts.ContainsKey(pluginId);
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyPath(PluginManifest manifest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(manifest.AssemblyPath))
|
||||
{
|
||||
throw new PluginLoadException(
|
||||
manifest.Info.Id,
|
||||
"Assembly path not specified in manifest");
|
||||
}
|
||||
|
||||
if (!File.Exists(manifest.AssemblyPath))
|
||||
{
|
||||
throw new PluginLoadException(
|
||||
manifest.Info.Id,
|
||||
$"Assembly not found: {manifest.AssemblyPath}");
|
||||
}
|
||||
|
||||
return manifest.AssemblyPath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when plugin loading fails.
|
||||
/// </summary>
|
||||
public class PluginLoadException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin ID that failed to load.
|
||||
/// </summary>
|
||||
public string PluginId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin load exception.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
public PluginLoadException(string pluginId, string message)
|
||||
: base($"Failed to load plugin '{pluginId}': {message}")
|
||||
{
|
||||
PluginId = pluginId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin load exception with inner exception.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="inner">The inner exception.</param>
|
||||
public PluginLoadException(string pluginId, string message, Exception inner)
|
||||
: base($"Failed to load plugin '{pluginId}': {message}", inner)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading plugins from assemblies with isolation support.
|
||||
/// </summary>
|
||||
public interface IHostPluginLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a plugin from a manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="trustLevel">Trust level to apply to the plugin.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result with the plugin instance.</returns>
|
||||
Task<PluginAssemblyLoadResult> LoadAsync(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Load a plugin from a type (for embedded plugins).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The plugin type.</typeparam>
|
||||
/// <param name="trustLevel">Trust level to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result.</returns>
|
||||
Task<PluginAssemblyLoadResult> LoadAsync<T>(
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct) where T : class, IPlugin, new();
|
||||
|
||||
/// <summary>
|
||||
/// Unload a plugin and release its resources.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to unload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnloadAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a plugin is currently loaded.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>True if loaded.</returns>
|
||||
bool IsLoaded(string pluginId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin assembly load operation.
|
||||
/// </summary>
|
||||
/// <param name="Instance">The loaded plugin instance.</param>
|
||||
/// <param name="Assembly">The loaded assembly.</param>
|
||||
/// <param name="LoadContext">The assembly load context (if isolated).</param>
|
||||
public sealed record PluginAssemblyLoadResult(
|
||||
IPlugin Instance,
|
||||
Assembly Assembly,
|
||||
PluginAssemblyLoadContext? LoadContext);
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Custom AssemblyLoadContext for loading plugins in isolation.
|
||||
/// Supports collectible assemblies for plugin unloading.
|
||||
/// </summary>
|
||||
public sealed class PluginAssemblyLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
private readonly WeakReference _weakReference;
|
||||
private readonly string _pluginPath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the load context is still alive (not collected).
|
||||
/// </summary>
|
||||
public bool IsAlive => _weakReference.IsAlive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin path this context was created for.
|
||||
/// </summary>
|
||||
public string PluginPath => _pluginPath;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin assembly load context.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the context (typically plugin ID).</param>
|
||||
/// <param name="pluginPath">Path to the plugin assembly.</param>
|
||||
/// <param name="isCollectible">Whether the context supports unloading.</param>
|
||||
public PluginAssemblyLoadContext(string name, string pluginPath, bool isCollectible = true)
|
||||
: base(name, isCollectible)
|
||||
{
|
||||
_pluginPath = pluginPath;
|
||||
_resolver = new AssemblyDependencyResolver(pluginPath);
|
||||
_weakReference = new WeakReference(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
// Try to resolve from the plugin's dependencies
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (assemblyPath != null)
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
// Fall back to default context (shared framework assemblies)
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
if (libraryPath != null)
|
||||
{
|
||||
return LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a plugin assembly load context that allows checking if it's been collected.
|
||||
/// </summary>
|
||||
public sealed class PluginLoadContextReference
|
||||
{
|
||||
private readonly WeakReference<PluginAssemblyLoadContext> _contextRef;
|
||||
private readonly string _pluginId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin ID.
|
||||
/// </summary>
|
||||
public string PluginId => _pluginId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the load context has been collected.
|
||||
/// </summary>
|
||||
public bool IsCollected => !_contextRef.TryGetTarget(out _);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new load context reference.
|
||||
/// </summary>
|
||||
/// <param name="context">The load context.</param>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
public PluginLoadContextReference(PluginAssemblyLoadContext context, string pluginId)
|
||||
{
|
||||
_contextRef = new WeakReference<PluginAssemblyLoadContext>(context);
|
||||
_pluginId = pluginId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the load context if it's still alive.
|
||||
/// </summary>
|
||||
/// <param name="context">The load context if alive.</param>
|
||||
/// <returns>True if the context is still alive.</returns>
|
||||
public bool TryGetContext(out PluginAssemblyLoadContext? context)
|
||||
{
|
||||
return _contextRef.TryGetTarget(out context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the unload of the load context.
|
||||
/// </summary>
|
||||
public void Unload()
|
||||
{
|
||||
if (_contextRef.TryGetTarget(out var context))
|
||||
{
|
||||
context.Unload();
|
||||
}
|
||||
}
|
||||
}
|
||||
418
src/Plugin/StellaOps.Plugin.Host/PluginHost.cs
Normal file
418
src/Plugin/StellaOps.Plugin.Host/PluginHost.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Context;
|
||||
using StellaOps.Plugin.Host.Dependencies;
|
||||
using StellaOps.Plugin.Host.Discovery;
|
||||
using StellaOps.Plugin.Host.Health;
|
||||
using StellaOps.Plugin.Host.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Loading;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Central coordinator for plugin lifecycle management.
|
||||
/// Handles discovery, loading, initialization, and shutdown of plugins.
|
||||
/// </summary>
|
||||
public sealed class PluginHost : IPluginHost
|
||||
{
|
||||
private readonly IPluginDiscovery _discovery;
|
||||
private readonly IHostPluginLoader _loader;
|
||||
private readonly IPluginLifecycleManager _lifecycle;
|
||||
private readonly IPluginHealthMonitor _healthMonitor;
|
||||
private readonly IPluginDependencyResolver _dependencyResolver;
|
||||
private readonly PluginContextFactory _contextFactory;
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly ILogger<PluginHost> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<string, LoadedPlugin> _plugins = new();
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
private CancellationTokenSource? _shutdownCts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, LoadedPlugin> Plugins => _plugins;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginStateChangedEventArgs>? PluginStateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginHealthChangedEventArgs>? PluginHealthChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin host.
|
||||
/// </summary>
|
||||
public PluginHost(
|
||||
IPluginDiscovery discovery,
|
||||
IHostPluginLoader loader,
|
||||
IPluginLifecycleManager lifecycle,
|
||||
IPluginHealthMonitor healthMonitor,
|
||||
IPluginDependencyResolver dependencyResolver,
|
||||
PluginContextFactory contextFactory,
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<PluginHost> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_discovery = discovery;
|
||||
_loader = loader;
|
||||
_lifecycle = lifecycle;
|
||||
_healthMonitor = healthMonitor;
|
||||
_dependencyResolver = dependencyResolver;
|
||||
_contextFactory = contextFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
|
||||
// Subscribe to health changes
|
||||
_healthMonitor.HealthChanged += OnPluginHealthChanged;
|
||||
_lifecycle.StateChanged += OnPluginStateTransition;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
_logger.LogInformation("Starting plugin host...");
|
||||
|
||||
// 1. Discover plugins
|
||||
var discovered = await _discovery.DiscoverAsync(_options.PluginPaths, ct);
|
||||
_logger.LogInformation("Discovered {Count} plugins", discovered.Count);
|
||||
|
||||
// 2. Validate dependency graph
|
||||
var cycles = _dependencyResolver.ValidateDependencyGraph(discovered);
|
||||
if (cycles.Count > 0)
|
||||
{
|
||||
var cycleDescriptions = string.Join("; ", cycles.Select(c => c.Description));
|
||||
_logger.LogError("Circular dependencies detected: {Cycles}", cycleDescriptions);
|
||||
|
||||
if (_options.FailOnPluginLoadError)
|
||||
throw new InvalidOperationException($"Circular dependencies detected: {cycleDescriptions}");
|
||||
}
|
||||
|
||||
// 3. Resolve dependencies and determine load order
|
||||
var loadOrder = _dependencyResolver.ResolveLoadOrder(discovered);
|
||||
|
||||
// 4. Load plugins in dependency order
|
||||
foreach (var manifest in loadOrder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load plugin {PluginId}", manifest.Info.Id);
|
||||
|
||||
if (_options.FailOnPluginLoadError)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start health monitoring
|
||||
await _healthMonitor.StartAsync(_shutdownCts.Token);
|
||||
|
||||
var activeCount = _plugins.Count(p => p.Value.State == PluginLifecycleState.Active);
|
||||
_logger.LogInformation(
|
||||
"Plugin host started with {ActiveCount}/{TotalCount} active plugins",
|
||||
activeCount, _plugins.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping plugin host...");
|
||||
|
||||
// Cancel ongoing operations
|
||||
if (_shutdownCts != null)
|
||||
{
|
||||
await _shutdownCts.CancelAsync();
|
||||
}
|
||||
|
||||
// Stop health monitoring
|
||||
await _healthMonitor.StopAsync(ct);
|
||||
|
||||
// Unload plugins in reverse dependency order
|
||||
var manifests = _plugins.Values.Select(p => p.Manifest).ToList();
|
||||
var unloadOrder = _dependencyResolver.ResolveUnloadOrder(manifests);
|
||||
|
||||
foreach (var pluginId in unloadOrder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UnloadPluginInternalAsync(pluginId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error unloading plugin {PluginId}", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Plugin host stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LoadedPlugin> LoadPluginAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
await _loadLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Discover manifest from source
|
||||
var manifest = await _discovery.DiscoverSingleAsync(source, ct);
|
||||
|
||||
// Check if already loaded
|
||||
if (_plugins.ContainsKey(manifest.Info.Id))
|
||||
throw new InvalidOperationException($"Plugin {manifest.Info.Id} is already loaded");
|
||||
|
||||
return await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LoadedPlugin> LoadPluginInternalAsync(
|
||||
Abstractions.Manifest.PluginManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var pluginId = manifest.Info.Id;
|
||||
_logger.LogDebug("Loading plugin {PluginId} v{Version}", pluginId, manifest.Info.Version);
|
||||
|
||||
// Register with lifecycle manager
|
||||
_lifecycle.Register(pluginId, PluginLifecycleState.Discovered);
|
||||
|
||||
// Transition to Loading state
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Loading, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Discovered, PluginLifecycleState.Loading);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Determine trust level
|
||||
var trustLevel = DetermineTrustLevel(manifest);
|
||||
|
||||
// 2. Load assembly and create instance
|
||||
var loadResult = await _loader.LoadAsync(manifest, trustLevel, ct);
|
||||
|
||||
// 3. Create plugin context
|
||||
var shutdownToken = _shutdownCts?.Token ?? CancellationToken.None;
|
||||
var context = _contextFactory.Create(manifest, trustLevel, shutdownToken);
|
||||
|
||||
// 4. Transition to Initializing
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Initializing, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Loading, PluginLifecycleState.Initializing);
|
||||
|
||||
// 5. Initialize plugin with timeout
|
||||
using var initCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
initCts.CancelAfter(_options.InitializationTimeout);
|
||||
|
||||
await loadResult.Instance.InitializeAsync(context, initCts.Token);
|
||||
|
||||
// 6. Transition to Active
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Active, ct);
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(loadResult.Instance)
|
||||
{
|
||||
Manifest = manifest,
|
||||
LoadedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_plugins[pluginId] = loadedPlugin;
|
||||
|
||||
// 7. Register with health monitor
|
||||
_healthMonitor.RegisterPlugin(loadedPlugin);
|
||||
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Initializing, PluginLifecycleState.Active);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded plugin {PluginId} v{Version} with capabilities [{Capabilities}]",
|
||||
pluginId, manifest.Info.Version, loadedPlugin.Capabilities);
|
||||
|
||||
return loadedPlugin;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load plugin {PluginId}", pluginId);
|
||||
|
||||
var currentState = _lifecycle.GetState(pluginId) ?? PluginLifecycleState.Discovered;
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Failed, ct);
|
||||
RaiseStateChanged(pluginId, currentState, PluginLifecycleState.Failed, ex.Message);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnloadPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
await _loadLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await UnloadPluginInternalAsync(pluginId, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UnloadPluginInternalAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_plugins.TryGetValue(pluginId, out var plugin))
|
||||
return;
|
||||
|
||||
_logger.LogDebug("Unloading plugin {PluginId}", pluginId);
|
||||
|
||||
var oldState = plugin.State;
|
||||
|
||||
// Transition to Stopping
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Stopping, ct);
|
||||
RaiseStateChanged(pluginId, oldState, PluginLifecycleState.Stopping);
|
||||
|
||||
try
|
||||
{
|
||||
// Unregister from health monitor
|
||||
_healthMonitor.UnregisterPlugin(pluginId);
|
||||
|
||||
// Dispose plugin with timeout
|
||||
using var disposeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
disposeCts.CancelAfter(_options.ShutdownTimeout);
|
||||
|
||||
await plugin.DisposeAsync();
|
||||
|
||||
// Transition to Stopped
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Stopped, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Stopping, PluginLifecycleState.Stopped);
|
||||
|
||||
// Unload assembly
|
||||
await _loader.UnloadAsync(pluginId, ct);
|
||||
|
||||
// Remove from registry
|
||||
_plugins.TryRemove(pluginId, out _);
|
||||
_lifecycle.Unregister(pluginId);
|
||||
|
||||
_logger.LogInformation("Unloaded plugin {PluginId}", pluginId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error unloading plugin {PluginId}", pluginId);
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Failed, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LoadedPlugin> ReloadPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_plugins.TryGetValue(pluginId, out var existing))
|
||||
throw new InvalidOperationException($"Plugin {pluginId} is not loaded");
|
||||
|
||||
var manifest = existing.Manifest
|
||||
?? throw new InvalidOperationException($"Plugin {pluginId} has no manifest");
|
||||
|
||||
await UnloadPluginAsync(pluginId, ct);
|
||||
|
||||
// Small delay to allow resources to be released
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
return await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<T> GetPluginsWithCapability<T>() where T : class
|
||||
{
|
||||
foreach (var plugin in _plugins.Values)
|
||||
{
|
||||
if (plugin.State == PluginLifecycleState.Active && plugin.HasCapability<T>())
|
||||
{
|
||||
var capability = plugin.GetCapability<T>();
|
||||
if (capability != null)
|
||||
yield return capability;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LoadedPlugin? GetPlugin(string pluginId) =>
|
||||
_plugins.TryGetValue(pluginId, out var plugin) ? plugin : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetCapability<T>(string pluginId) where T : class =>
|
||||
GetPlugin(pluginId)?.GetCapability<T>();
|
||||
|
||||
private PluginTrustLevel DetermineTrustLevel(Abstractions.Manifest.PluginManifest manifest)
|
||||
{
|
||||
// Built-in plugins are always trusted
|
||||
if (_options.BuiltInPluginIds.Contains(manifest.Info.Id))
|
||||
return PluginTrustLevel.BuiltIn;
|
||||
|
||||
// Check trusted plugins list
|
||||
if (_options.TrustedPluginIds.Contains(manifest.Info.Id))
|
||||
return PluginTrustLevel.Trusted;
|
||||
|
||||
// Check trusted vendors
|
||||
if (_options.TrustedVendors.Contains(manifest.Info.Vendor))
|
||||
return PluginTrustLevel.Trusted;
|
||||
|
||||
// Default to untrusted
|
||||
return PluginTrustLevel.Untrusted;
|
||||
}
|
||||
|
||||
private void OnPluginHealthChanged(object? sender, PluginHealthChangedEventArgs e)
|
||||
{
|
||||
// Handle unhealthy plugins
|
||||
if (e.NewStatus == HealthStatus.Unhealthy && _options.AutoRecoverUnhealthyPlugins)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Plugin {PluginId} unhealthy, attempting recovery", e.PluginId);
|
||||
await ReloadPluginAsync(e.PluginId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to recover plugin {PluginId}", e.PluginId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
PluginHealthChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private void OnPluginStateTransition(object? sender, PluginStateTransitionEventArgs e)
|
||||
{
|
||||
// Already handled through RaiseStateChanged
|
||||
}
|
||||
|
||||
private void RaiseStateChanged(
|
||||
string pluginId,
|
||||
PluginLifecycleState oldState,
|
||||
PluginLifecycleState newState,
|
||||
string? reason = null)
|
||||
{
|
||||
PluginStateChanged?.Invoke(this, new PluginStateChangedEventArgs
|
||||
{
|
||||
PluginId = pluginId,
|
||||
OldState = oldState,
|
||||
NewState = newState,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None);
|
||||
|
||||
_healthMonitor.HealthChanged -= OnPluginHealthChanged;
|
||||
_lifecycle.StateChanged -= OnPluginStateTransition;
|
||||
|
||||
_shutdownCts?.Dispose();
|
||||
_loadLock.Dispose();
|
||||
}
|
||||
}
|
||||
118
src/Plugin/StellaOps.Plugin.Host/PluginHostOptions.cs
Normal file
118
src/Plugin/StellaOps.Plugin.Host/PluginHostOptions.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the plugin host.
|
||||
/// </summary>
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Plugins";
|
||||
|
||||
/// <summary>
|
||||
/// Paths to search for plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PluginPaths { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// IDs of built-in plugins (always trusted).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> BuiltInPluginIds { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// IDs of explicitly trusted plugins.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedPluginIds { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Vendor names that are trusted.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedVendors { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for plugin initialization.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for plugin shutdown.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Interval between health checks.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:05", "01:00:00")]
|
||||
public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual health checks.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:01:00")]
|
||||
public TimeSpan HealthCheckTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if any plugin fails to load.
|
||||
/// </summary>
|
||||
public bool FailOnPluginLoadError { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically reload unhealthy plugins.
|
||||
/// </summary>
|
||||
public bool AutoRecoverUnhealthyPlugins { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before attempting recovery.
|
||||
/// </summary>
|
||||
[Range(1, 10)]
|
||||
public int ConsecutiveFailuresBeforeRecovery { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of recovery attempts per plugin.
|
||||
/// </summary>
|
||||
[Range(0, 10)]
|
||||
public int MaxRecoveryAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay between recovery attempts.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan RecoveryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Enable plugin isolation using separate AssemblyLoadContext.
|
||||
/// </summary>
|
||||
public bool EnableAssemblyIsolation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to load plugins in parallel.
|
||||
/// </summary>
|
||||
public bool ParallelLoading { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum degree of parallelism for parallel loading.
|
||||
/// </summary>
|
||||
[Range(1, 32)]
|
||||
public int MaxParallelLoads { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable hot reload support.
|
||||
/// </summary>
|
||||
public bool EnableHotReload { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// File patterns to watch for hot reload.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> HotReloadPatterns { get; set; } = ["*.dll"];
|
||||
|
||||
/// <summary>
|
||||
/// Debounce interval for hot reload file changes.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:00.100", "00:00:10")]
|
||||
public TimeSpan HotReloadDebounce { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.Host</PackageId>
|
||||
<Description>Plugin host and lifecycle manager for the Stella Ops platform</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.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user