279 lines
9.6 KiB
C#
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);
|
|
}
|
|
}
|