Initial commit (history squashed)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Plugin.Internal;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class PluginDependencyInjectionExtensions
|
||||
{
|
||||
public static IServiceCollection RegisterPluginRoutines(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
PluginHostOptions options,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (services == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
if (configuration == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var loadResult = PluginHost.LoadPlugins(options, logger);
|
||||
|
||||
foreach (var plugin in loadResult.Plugins)
|
||||
{
|
||||
foreach (var routine in CreateRoutines(plugin.Assembly))
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Registering DI routine '{RoutineType}' from plugin '{PluginAssembly}'.",
|
||||
routine.GetType().FullName,
|
||||
plugin.Assembly.FullName);
|
||||
|
||||
routine.Register(services, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadResult.MissingOrderedPlugins.Count > 0)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Some ordered plugins were not found: {Missing}",
|
||||
string.Join(", ", loadResult.MissingOrderedPlugins));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
|
||||
{
|
||||
foreach (var type in assembly.GetLoadableTypes())
|
||||
{
|
||||
if (type is null || type.IsAbstract || type.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? instance;
|
||||
try
|
||||
{
|
||||
instance = Activator.CreateInstance(type);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instance is IDependencyInjectionRoutine routine)
|
||||
{
|
||||
yield return routine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Plugin.DependencyInjection;
|
||||
|
||||
public static class StellaOpsPluginRegistration
|
||||
{
|
||||
public static IServiceCollection RegisterStellaOpsPlugin(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// No-op today but reserved for future plugin infrastructure services.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
public IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
return services.RegisterStellaOpsPlugin(configuration);
|
||||
}
|
||||
}
|
||||
21
src/StellaOps.Plugin/Hosting/PluginAssembly.cs
Normal file
21
src/StellaOps.Plugin/Hosting/PluginAssembly.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginAssembly
|
||||
{
|
||||
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
|
||||
{
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
public string AssemblyPath { get; }
|
||||
|
||||
public Assembly Assembly { get; }
|
||||
|
||||
internal PluginLoadContext LoadContext { get; }
|
||||
|
||||
public override string ToString() => Assembly.FullName ?? AssemblyPath;
|
||||
}
|
||||
216
src/StellaOps.Plugin/Hosting/PluginHost.cs
Normal file
216
src/StellaOps.Plugin/Hosting/PluginHost.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public static class PluginHost
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var baseDirectory = options.ResolveBaseDirectory();
|
||||
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
|
||||
|
||||
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(pluginDirectory);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
|
||||
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
|
||||
}
|
||||
|
||||
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
|
||||
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
|
||||
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
|
||||
|
||||
var loaded = new List<PluginAssembly>(orderedFiles.Count);
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
foreach (var file in orderedFiles)
|
||||
{
|
||||
if (LoadedPlugins.TryGetValue(file, out var existing))
|
||||
{
|
||||
loaded.Add(existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loadContext = new PluginLoadContext(file);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(file);
|
||||
var descriptor = new PluginAssembly(file, assembly, loadContext);
|
||||
LoadedPlugins[file] = descriptor;
|
||||
loaded.Add(descriptor);
|
||||
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
|
||||
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
|
||||
}
|
||||
|
||||
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.PluginsDirectory))
|
||||
{
|
||||
return Path.Combine(baseDirectory, "PluginBinaries");
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(options.PluginsDirectory))
|
||||
{
|
||||
return options.PluginsDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(baseDirectory, options.PluginsDirectory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
if (options.SearchPatterns.Count > 0)
|
||||
{
|
||||
patterns.AddRange(options.SearchPatterns);
|
||||
}
|
||||
else
|
||||
{
|
||||
var prefixes = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
|
||||
{
|
||||
prefixes.Add(options.PrimaryPrefix);
|
||||
}
|
||||
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
|
||||
{
|
||||
prefixes.Add(entryName);
|
||||
}
|
||||
|
||||
prefixes.AddRange(options.AdditionalPrefixes);
|
||||
|
||||
if (prefixes.Count == 0)
|
||||
{
|
||||
// Fallback to directory name
|
||||
prefixes.Add(Path.GetFileName(pluginDirectory));
|
||||
}
|
||||
|
||||
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
|
||||
{
|
||||
patterns.Add($"{prefix}.Plugin.*.dll");
|
||||
}
|
||||
}
|
||||
|
||||
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
private static List<string> DiscoverPluginFiles(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
bool recurse,
|
||||
ILogger? logger)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
foreach (var pattern in searchPatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
|
||||
{
|
||||
if (IsHiddenPath(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.Add(file))
|
||||
{
|
||||
files.Add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory could be removed between the existence check and enumeration.
|
||||
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static List<string> ApplyExplicitOrdering(
|
||||
List<string> discoveredFiles,
|
||||
IList<string> pluginOrder,
|
||||
out List<string> missingNames)
|
||||
{
|
||||
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
|
||||
{
|
||||
missingNames = new List<string>();
|
||||
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return discoveredFiles;
|
||||
}
|
||||
|
||||
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
|
||||
var fileLookup = discoveredFiles.ToDictionary(
|
||||
k => Path.GetFileNameWithoutExtension(k),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var specified = new List<string>();
|
||||
foreach (var name in pluginOrder)
|
||||
{
|
||||
if (fileLookup.TryGetValue(name, out var file))
|
||||
{
|
||||
specified.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
var unspecified = discoveredFiles
|
||||
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
missingNames = pluginOrder
|
||||
.Where(name => !fileLookup.ContainsKey(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
specified.AddRange(unspecified);
|
||||
return specified;
|
||||
}
|
||||
|
||||
private static bool IsHiddenPath(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
59
src/StellaOps.Plugin/Hosting/PluginHostOptions.cs
Normal file
59
src/StellaOps.Plugin/Hosting/PluginHostOptions.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
private readonly List<string> additionalPrefixes = new();
|
||||
private readonly List<string> pluginOrder = new();
|
||||
private readonly List<string> searchPatterns = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
|
||||
/// </summary>
|
||||
public string? BaseDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory that contains plugin assemblies. Relative values are resolved against <see cref="BaseDirectory" />.
|
||||
/// Defaults to <c>PluginBinaries</c> under the base directory.
|
||||
/// </summary>
|
||||
public string? PluginsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
|
||||
/// </summary>
|
||||
public string? PrimaryPrefix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional prefixes that should be considered when building search patterns.
|
||||
/// </summary>
|
||||
public IList<string> AdditionalPrefixes => additionalPrefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit plugin ordering expressed as assembly names without extension.
|
||||
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
|
||||
/// </summary>
|
||||
public IList<string> PluginOrder => pluginOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
|
||||
/// </summary>
|
||||
public IList<string> SearchPatterns => searchPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the plugin directory will be created if it does not exist.
|
||||
/// </summary>
|
||||
public bool EnsureDirectoryExists { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether sub-directories should be scanned. Defaults to true.
|
||||
/// </summary>
|
||||
public bool RecursiveSearch { get; set; } = true;
|
||||
|
||||
internal string ResolveBaseDirectory()
|
||||
=> string.IsNullOrWhiteSpace(BaseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: Path.GetFullPath(BaseDirectory);
|
||||
}
|
||||
26
src/StellaOps.Plugin/Hosting/PluginHostResult.cs
Normal file
26
src/StellaOps.Plugin/Hosting/PluginHostResult.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostResult
|
||||
{
|
||||
internal PluginHostResult(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
IReadOnlyList<PluginAssembly> plugins,
|
||||
IReadOnlyList<string> missingOrderedPlugins)
|
||||
{
|
||||
PluginDirectory = pluginDirectory;
|
||||
SearchPatterns = searchPatterns;
|
||||
Plugins = plugins;
|
||||
MissingOrderedPlugins = missingOrderedPlugins;
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public IReadOnlyList<string> SearchPatterns { get; }
|
||||
|
||||
public IReadOnlyList<PluginAssembly> Plugins { get; }
|
||||
|
||||
public IReadOnlyList<string> MissingOrderedPlugins { get; }
|
||||
}
|
||||
79
src/StellaOps.Plugin/Hosting/PluginLoadContext.cs
Normal file
79
src/StellaOps.Plugin/Hosting/PluginLoadContext.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
internal sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver resolver;
|
||||
private readonly IEnumerable<Assembly> hostAssemblies;
|
||||
|
||||
public PluginLoadContext(string pluginPath)
|
||||
: base(isCollectible: false)
|
||||
{
|
||||
resolver = new AssemblyDependencyResolver(pluginPath);
|
||||
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
|
||||
}
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
|
||||
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
|
||||
a.GetName().Name,
|
||||
assemblyName.Name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (!string.IsNullOrEmpty(assemblyPath))
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
if (!string.IsNullOrEmpty(libraryPath))
|
||||
{
|
||||
return LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
|
||||
{
|
||||
if (hostAssembly.Version == pluginAssembly.Version)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version is null || pluginAssembly.Version is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
|
||||
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
21
src/StellaOps.Plugin/Internal/ReflectionExtensions.cs
Normal file
21
src/StellaOps.Plugin/Internal/ReflectionExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Plugin.Internal;
|
||||
|
||||
internal static class ReflectionExtensions
|
||||
{
|
||||
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(static t => t is not null)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/StellaOps.Plugin/PluginContracts.cs
Normal file
172
src/StellaOps.Plugin/PluginContracts.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin;
|
||||
|
||||
public interface IAvailabilityPlugin
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IFeedConnector
|
||||
{
|
||||
string SourceName { get; }
|
||||
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IFeedExporter
|
||||
{
|
||||
string Name { get; }
|
||||
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IConnectorPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedConnector Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public interface IExporterPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IFeedExporter Create(IServiceProvider services);
|
||||
}
|
||||
|
||||
public sealed class PluginCatalog
|
||||
{
|
||||
private readonly List<Assembly> _assemblies = new();
|
||||
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginCatalog AddAssembly(Assembly assembly)
|
||||
{
|
||||
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
|
||||
if (_assemblies.Contains(assembly))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_assemblies.Add(assembly);
|
||||
if (!string.IsNullOrWhiteSpace(assembly.Location))
|
||||
{
|
||||
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Feedser.*.dll")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
|
||||
|
||||
var fullDirectory = Path.GetFullPath(directory);
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = fullDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false,
|
||||
};
|
||||
options.SearchPatterns.Add(searchPattern);
|
||||
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
|
||||
foreach (var plugin in result.Plugins)
|
||||
{
|
||||
AddAssembly(plugin.Assembly);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
|
||||
|
||||
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetConnectorPlugins(), services);
|
||||
|
||||
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
|
||||
=> FilterAvailable(GetExporterPlugins(), services);
|
||||
|
||||
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
|
||||
where TPlugin : IAvailabilityPlugin
|
||||
{
|
||||
var list = new List<TPlugin>();
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (plugin.IsAvailable(services))
|
||||
{
|
||||
list.Add(plugin);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Treat exceptions as plugin not available.
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PluginLoader
|
||||
{
|
||||
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
|
||||
where TPlugin : class
|
||||
{
|
||||
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
|
||||
|
||||
var plugins = new List<TPlugin>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
foreach (var candidate in SafeGetTypes(assembly))
|
||||
{
|
||||
if (candidate.IsAbstract || candidate.IsInterface)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(TPlugin).IsAssignableFrom(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = candidate.FullName ?? candidate.Name;
|
||||
if (key is null || !seen.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.Add(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types.Where(t => t is not null)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
src/StellaOps.Plugin/StellaOps.Plugin.csproj
Normal file
19
src/StellaOps.Plugin/StellaOps.Plugin.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.DependencyInjection\\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user