Files
git.stella-ops.org/src/__Libraries/StellaOps.Plugin/Hosting/PluginHost.cs
StellaOps Bot 564df71bfb
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

220 lines
7.5 KiB
C#

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))
{
var defaultDirectory = !string.IsNullOrWhiteSpace(options.PrimaryPrefix)
? $"{options.PrimaryPrefix}.PluginBinaries"
: "PluginBinaries";
return Path.Combine(baseDirectory, defaultDirectory);
}
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;
}
}