Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
2025-10-28 15:10:40 +02:00

279 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Cli.Plugins;
internal sealed class CliCommandModuleLoader
{
private readonly IServiceProvider _services;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<CliCommandModuleLoader> _logger;
private readonly RestartOnlyCliPluginGuard _guard = new();
private IReadOnlyList<ICliCommandModule> _modules = Array.Empty<ICliCommandModule>();
private bool _loaded;
public CliCommandModuleLoader(
IServiceProvider services,
StellaOpsCliOptions options,
ILogger<CliCommandModuleLoader> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyList<ICliCommandModule> LoadModules()
{
if (_loaded)
{
return _modules;
}
var pluginOptions = _options.Plugins ?? new StellaOpsCliPluginOptions();
var baseDirectory = ResolveBaseDirectory(pluginOptions);
var pluginsDirectory = ResolvePluginsDirectory(pluginOptions, baseDirectory);
var searchPatterns = ResolveSearchPatterns(pluginOptions);
var manifestPattern = string.IsNullOrWhiteSpace(pluginOptions.ManifestSearchPattern)
? "*.manifest.json"
: pluginOptions.ManifestSearchPattern;
_logger.LogDebug("Loading CLI plug-ins from '{Directory}' (base: '{Base}').", pluginsDirectory, baseDirectory);
var manifestLoader = new CliPluginManifestLoader(pluginsDirectory, manifestPattern);
IReadOnlyList<CliPluginManifest> manifests;
try
{
manifests = manifestLoader.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enumerate CLI plug-in manifests from '{Directory}'.", pluginsDirectory);
manifests = Array.Empty<CliPluginManifest>();
}
if (manifests.Count == 0)
{
_logger.LogInformation("No CLI plug-in manifests discovered under '{Directory}'.", pluginsDirectory);
_loaded = true;
_guard.Seal();
_modules = Array.Empty<ICliCommandModule>();
return _modules;
}
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = true,
PrimaryPrefix = "StellaOps.Cli"
};
foreach (var pattern in searchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var ordered in pluginOptions.PluginOrder ?? Array.Empty<string>())
{
if (!string.IsNullOrWhiteSpace(ordered))
{
hostOptions.PluginOrder.Add(ordered);
}
}
var loadResult = PluginHost.LoadPlugins(hostOptions, _logger);
var assemblies = loadResult.Plugins.ToDictionary(
descriptor => Normalize(descriptor.AssemblyPath),
descriptor => descriptor.Assembly,
StringComparer.OrdinalIgnoreCase);
var modules = new List<ICliCommandModule>(manifests.Count);
foreach (var manifest in manifests)
{
try
{
var assemblyPath = ResolveAssemblyPath(manifest);
_guard.EnsureRegistrationAllowed(assemblyPath);
if (!assemblies.TryGetValue(assemblyPath, out var assembly))
{
if (!File.Exists(assemblyPath))
{
throw new FileNotFoundException($"Plug-in assembly '{assemblyPath}' referenced by manifest '{manifest.Id}' was not found.");
}
assembly = Assembly.LoadFrom(assemblyPath);
assemblies[assemblyPath] = assembly;
}
var module = CreateModule(assembly, manifest);
if (module is null)
{
continue;
}
modules.Add(module);
_logger.LogInformation("Registered CLI plug-in '{PluginId}' ({PluginName}) from '{AssemblyPath}'.", manifest.Id, module.Name, assemblyPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register CLI plug-in '{PluginId}'.", manifest.Id);
}
}
_modules = modules;
_loaded = true;
_guard.Seal();
return _modules;
}
public void RegisterModules(RootCommand root, Option<bool> verboseOption, CancellationToken cancellationToken)
{
if (root is null)
{
throw new ArgumentNullException(nameof(root));
}
if (verboseOption is null)
{
throw new ArgumentNullException(nameof(verboseOption));
}
var modules = LoadModules();
if (modules.Count == 0)
{
return;
}
foreach (var module in modules)
{
if (!module.IsAvailable(_services))
{
_logger.LogDebug("CLI plug-in '{Name}' reported unavailable; skipping registration.", module.Name);
continue;
}
try
{
module.RegisterCommands(root, _services, _options, verboseOption, cancellationToken);
_logger.LogInformation("CLI plug-in '{Name}' commands registered.", module.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "CLI plug-in '{Name}' failed to register commands.", module.Name);
}
}
}
private static string ResolveAssemblyPath(CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' does not define an entry point.");
}
var assemblyPath = manifest.EntryPoint.Assembly;
if (string.IsNullOrWhiteSpace(assemblyPath))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' specifies an empty assembly path.");
}
if (!Path.IsPathRooted(assemblyPath))
{
if (string.IsNullOrWhiteSpace(manifest.SourceDirectory))
{
throw new InvalidOperationException($"Manifest '{manifest.SourcePath}' cannot resolve relative assembly path without source directory metadata.");
}
assemblyPath = Path.Combine(manifest.SourceDirectory, assemblyPath);
}
return Normalize(assemblyPath);
}
private ICliCommandModule? CreateModule(Assembly assembly, CliPluginManifest manifest)
{
if (manifest.EntryPoint is null)
{
return null;
}
var type = assembly.GetType(manifest.EntryPoint.TypeName, throwOnError: true);
if (type is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' could not be loaded from assembly '{assembly.FullName}'.");
}
var module = ActivatorUtilities.CreateInstance(_services, type) as ICliCommandModule;
if (module is null)
{
throw new InvalidOperationException($"Plug-in type '{manifest.EntryPoint.TypeName}' does not implement {nameof(ICliCommandModule)}.");
}
return module;
}
private static string ResolveBaseDirectory(StellaOpsCliPluginOptions options)
{
var baseDirectory = options.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = AppContext.BaseDirectory;
}
return Path.GetFullPath(baseDirectory);
}
private static string ResolvePluginsDirectory(StellaOpsCliPluginOptions options, string baseDirectory)
{
var directory = options.Directory;
if (string.IsNullOrWhiteSpace(directory))
{
directory = Path.Combine("plugins", "cli");
}
directory = directory.Trim();
if (!Path.IsPathRooted(directory))
{
directory = Path.Combine(baseDirectory, directory);
}
return Path.GetFullPath(directory);
}
private static IReadOnlyList<string> ResolveSearchPatterns(StellaOpsCliPluginOptions options)
{
if (options.SearchPatterns is null || options.SearchPatterns.Count == 0)
{
return new[] { "StellaOps.Cli.Plugin.*.dll" };
}
return options.SearchPatterns
.Where(pattern => !string.IsNullOrWhiteSpace(pattern))
.Select(pattern => pattern.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string Normalize(string path)
{
var full = Path.GetFullPath(path);
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}