Restructure solution layout by module
This commit is contained in:
278
src/Cli/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal file
278
src/Cli/StellaOps.Cli/Plugins/CliCommandModuleLoader.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
39
src/Cli/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal file
39
src/Cli/StellaOps.Cli/Plugins/CliPluginManifest.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public sealed record CliPluginManifest
|
||||
{
|
||||
public const string CurrentSchemaVersion = "1.0";
|
||||
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = "0.0.0";
|
||||
|
||||
public bool RequiresRestart { get; init; } = true;
|
||||
|
||||
public CliPluginEntryPoint? EntryPoint { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
public string? SourceDirectory { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CliPluginEntryPoint
|
||||
{
|
||||
public string Type { get; init; } = "dotnet";
|
||||
|
||||
public string Assembly { get; init; } = string.Empty;
|
||||
|
||||
public string TypeName { get; init; } = string.Empty;
|
||||
}
|
||||
150
src/Cli/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal file
150
src/Cli/StellaOps.Cli/Plugins/CliPluginManifestLoader.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class CliPluginManifestLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly string _searchPattern;
|
||||
|
||||
public CliPluginManifestLoader(string directory, string searchPattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new ArgumentException("Plug-in manifest directory is required.", nameof(directory));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchPattern))
|
||||
{
|
||||
throw new ArgumentException("Manifest search pattern is required.", nameof(searchPattern));
|
||||
}
|
||||
|
||||
_directory = Path.GetFullPath(directory);
|
||||
_searchPattern = searchPattern;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CliPluginManifest>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(_directory))
|
||||
{
|
||||
return Array.Empty<CliPluginManifest>();
|
||||
}
|
||||
|
||||
var manifests = new List<CliPluginManifest>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, _searchPattern, SearchOption.AllDirectories))
|
||||
{
|
||||
if (IsHidden(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifest = await DeserializeAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
manifests.Add(manifest);
|
||||
}
|
||||
|
||||
return manifests
|
||||
.OrderBy(static m => m.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static m => m.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsHidden(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (name.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<CliPluginManifest> DeserializeAsync(string file, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
|
||||
CliPluginManifest? manifest;
|
||||
|
||||
try
|
||||
{
|
||||
manifest = await JsonSerializer.DeserializeAsync<CliPluginManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse CLI plug-in manifest '{file}'.", ex);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"CLI plug-in manifest '{file}' is empty or invalid.");
|
||||
}
|
||||
|
||||
ValidateManifest(manifest, file);
|
||||
|
||||
var directory = Path.GetDirectoryName(file);
|
||||
return manifest with
|
||||
{
|
||||
SourcePath = file,
|
||||
SourceDirectory = directory
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateManifest(CliPluginManifest manifest, string file)
|
||||
{
|
||||
if (!string.Equals(manifest.SchemaVersion, CliPluginManifest.CurrentSchemaVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{file}' uses unsupported schema version '{manifest.SchemaVersion}'. Expected '{CliPluginManifest.CurrentSchemaVersion}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify a non-empty 'id'.");
|
||||
}
|
||||
|
||||
if (manifest.EntryPoint is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an 'entryPoint'.");
|
||||
}
|
||||
|
||||
if (!string.Equals(manifest.EntryPoint.Type, "dotnet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' entry point type '{manifest.EntryPoint.Type}' is not supported. Expected 'dotnet'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.Assembly))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point assembly.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.EntryPoint.TypeName))
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must specify an entry point type.");
|
||||
}
|
||||
|
||||
if (!manifest.RequiresRestart)
|
||||
{
|
||||
throw new InvalidOperationException($"Manifest '{file}' must set 'requiresRestart' to true.");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Cli/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal file
20
src/Cli/StellaOps.Cli/Plugins/ICliCommandModule.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Threading;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
public interface ICliCommandModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
bool IsAvailable(IServiceProvider services);
|
||||
|
||||
void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
StellaOpsCliOptions options,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
41
src/Cli/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal file
41
src/Cli/StellaOps.Cli/Plugins/RestartOnlyCliPluginGuard.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Cli.Plugins;
|
||||
|
||||
internal sealed class RestartOnlyCliPluginGuard
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _plugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _sealed;
|
||||
|
||||
public IReadOnlyCollection<string> KnownPlugins => _plugins.Keys.ToArray();
|
||||
|
||||
public bool IsSealed => Volatile.Read(ref _sealed);
|
||||
|
||||
public void EnsureRegistrationAllowed(string pluginPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginPath);
|
||||
|
||||
var normalized = Normalize(pluginPath);
|
||||
if (IsSealed && !_plugins.ContainsKey(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Plug-in '{pluginPath}' cannot be registered after startup. Restart required.");
|
||||
}
|
||||
|
||||
_plugins.TryAdd(normalized, 0);
|
||||
}
|
||||
|
||||
public void Seal()
|
||||
{
|
||||
Volatile.Write(ref _sealed, true);
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user