release orchestrator v1 draft and build fixes

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

View File

@@ -0,0 +1,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;
}
}
}

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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 = []
};
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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