up
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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,278 +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);
}
}
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);
}
}

View File

@@ -1,39 +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;
}
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;
}

View File

@@ -1,150 +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.");
}
}
}
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.");
}
}
}

View File

@@ -1,20 +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);
}
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);
}

View File

@@ -1,41 +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);
}
}
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);
}
}