Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
233
src/__Libraries/StellaOps.Plugin/Manifest/PluginManifest.cs
Normal file
233
src/__Libraries/StellaOps.Plugin/Manifest/PluginManifest.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Plugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Unified plugin manifest schema (v2.0).
|
||||
/// All modules use this schema for plugin.json files.
|
||||
/// </summary>
|
||||
public sealed record PluginManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version. Current version is "2.0".
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "2.0";
|
||||
|
||||
/// <summary>
|
||||
/// Unique plugin identifier in format: stellaops.{module}.{plugin}
|
||||
/// Examples: stellaops.router.tcp, stellaops.scanner.lang.dotnet
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin version (SemVer).
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Assembly descriptor for loading the plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("assembly")]
|
||||
public required PluginAssemblyDescriptor Assembly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point type for plugin initialization (optional).
|
||||
/// If not specified, the loader will scan for plugin interfaces.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin capabilities (e.g., "signing:ES256", "transport:tcp", "analyzer:dotnet").
|
||||
/// </summary>
|
||||
[JsonPropertyName("capabilities")]
|
||||
public IReadOnlyList<string> Capabilities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Supported platforms (e.g., "linux-x64", "win-x64", "osx-arm64").
|
||||
/// Empty list means all platforms.
|
||||
/// </summary>
|
||||
[JsonPropertyName("platforms")]
|
||||
public IReadOnlyList<string> Platforms { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Compliance standards (e.g., "NIST", "FIPS-140-3", "GOST", "eIDAS").
|
||||
/// </summary>
|
||||
[JsonPropertyName("compliance")]
|
||||
public IReadOnlyList<string> Compliance { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Jurisdiction restriction (e.g., "world", "russia", "china", "eu", "korea").
|
||||
/// </summary>
|
||||
[JsonPropertyName("jurisdiction")]
|
||||
public string? Jurisdiction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Loading priority (0-100). Higher priority plugins are loaded first.
|
||||
/// Default is 100 for user plugins, 50 for built-in plugins.
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the plugin is enabled by default.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the plugin is enabled by default in new installations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabledByDefault")]
|
||||
public bool EnabledByDefault { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-specific configuration options.
|
||||
/// </summary>
|
||||
[JsonPropertyName("options")]
|
||||
public IReadOnlyDictionary<string, object?> Options { get; init; } = new Dictionary<string, object?>();
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (e.g., author, license, homepage).
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Plugin dependencies (other plugin IDs).
|
||||
/// </summary>
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<string> Dependencies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Conditional compilation symbol required to build this plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conditionalCompilation")]
|
||||
public string? ConditionalCompilation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor for the plugin assembly location.
|
||||
/// </summary>
|
||||
public sealed record PluginAssemblyDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Relative path to the assembly DLL from the plugin directory.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional SHA256 hash of the assembly for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional signature file path (.sig) for cosign verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signaturePath")]
|
||||
public string? SignaturePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration for a plugin loaded from config.yaml.
|
||||
/// </summary>
|
||||
public sealed record PluginRuntimeConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin ID (must match manifest ID).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name override (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the plugin is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority override (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int? Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-specific runtime configuration.
|
||||
/// Supports environment variable substitution: ${VAR:-default}
|
||||
/// </summary>
|
||||
[JsonPropertyName("config")]
|
||||
public IReadOnlyDictionary<string, object?> Config { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loaded plugin combining manifest, config, and assembly.
|
||||
/// </summary>
|
||||
public sealed class LoadedPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new loaded plugin instance.
|
||||
/// </summary>
|
||||
public LoadedPlugin(
|
||||
PluginManifest manifest,
|
||||
PluginRuntimeConfig? config,
|
||||
Hosting.PluginAssembly? assembly)
|
||||
{
|
||||
Manifest = manifest ?? throw new ArgumentNullException(nameof(manifest));
|
||||
Config = config;
|
||||
Assembly = assembly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The plugin manifest from plugin.json.
|
||||
/// </summary>
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The runtime configuration from config.yaml (may be null).
|
||||
/// </summary>
|
||||
public PluginRuntimeConfig? Config { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The loaded assembly (null if not yet loaded or load failed).
|
||||
/// </summary>
|
||||
public Hosting.PluginAssembly? Assembly { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the plugin is effectively enabled (manifest + config).
|
||||
/// </summary>
|
||||
public bool IsEnabled => Config?.Enabled ?? Manifest.Enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Effective priority (config override or manifest default).
|
||||
/// </summary>
|
||||
public int EffectivePriority => Config?.Priority ?? Manifest.Priority;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin ID from manifest.
|
||||
/// </summary>
|
||||
public string Id => Manifest.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Effective display name (config override or manifest default).
|
||||
/// </summary>
|
||||
public string Name => Config?.Name ?? Manifest.Name;
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Plugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Unified plugin loader that discovers plugins from manifest files.
|
||||
/// Replaces module-specific loaders with a standardized approach.
|
||||
/// </summary>
|
||||
public sealed class PluginManifestLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly ILogger? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin manifest loader.
|
||||
/// </summary>
|
||||
public PluginManifestLoader(ILogger? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins from the specified configuration directory.
|
||||
/// </summary>
|
||||
/// <param name="options">Loading options including base directory and filters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Registry result with loaded plugins and any errors.</returns>
|
||||
public async Task<PluginRegistryResult> LoadAsync(
|
||||
PluginRegistryOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var configDir = options.BaseDirectory;
|
||||
var registryPath = Path.Combine(configDir, "registry.yaml");
|
||||
var errors = new List<PluginLoadError>();
|
||||
var loadedPlugins = new List<LoadedPlugin>();
|
||||
|
||||
// Load registry.yaml if it exists
|
||||
PluginRegistry? registry = null;
|
||||
if (File.Exists(registryPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var yaml = await File.ReadAllTextAsync(registryPath, cancellationToken);
|
||||
registry = YamlDeserializer.Deserialize<PluginRegistry>(yaml);
|
||||
_logger?.LogDebug("Loaded plugin registry from {Path}", registryPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to load plugin registry from {Path}", registryPath);
|
||||
return new PluginRegistryResult
|
||||
{
|
||||
RegistryPath = registryPath,
|
||||
ErrorMessage = $"Failed to load registry: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogDebug("No registry.yaml found at {Path}, will discover plugins from subdirectories", registryPath);
|
||||
}
|
||||
|
||||
// Discover plugin directories
|
||||
var pluginDirectories = DiscoverPluginDirectories(configDir);
|
||||
_logger?.LogDebug("Discovered {Count} plugin directories in {Directory}", pluginDirectories.Count, configDir);
|
||||
|
||||
// Load each plugin
|
||||
foreach (var pluginDir in pluginDirectories)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pluginId = Path.GetFileName(pluginDir);
|
||||
var manifestPath = Path.Combine(pluginDir, "plugin.json");
|
||||
|
||||
// Load manifest
|
||||
PluginManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = await LoadManifestAsync(manifestPath, cancellationToken);
|
||||
if (manifest is null)
|
||||
{
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = pluginId,
|
||||
Category = PluginLoadErrorCategory.ManifestNotFound,
|
||||
Message = $"Manifest not found at {manifestPath}"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = pluginId,
|
||||
Category = PluginLoadErrorCategory.ManifestInvalid,
|
||||
Message = $"Failed to load manifest: {ex.Message}",
|
||||
Exception = ex
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check platform filter
|
||||
if (!IsPlatformSupported(manifest, options.Platform))
|
||||
{
|
||||
_logger?.LogDebug("Plugin {PluginId} skipped: platform mismatch", manifest.Id);
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = manifest.Id,
|
||||
Category = PluginLoadErrorCategory.PlatformMismatch,
|
||||
Message = $"Plugin does not support platform {options.Platform}"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check jurisdiction filter
|
||||
if (!IsJurisdictionSupported(manifest, options.Jurisdiction))
|
||||
{
|
||||
_logger?.LogDebug("Plugin {PluginId} skipped: jurisdiction mismatch", manifest.Id);
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = manifest.Id,
|
||||
Category = PluginLoadErrorCategory.JurisdictionMismatch,
|
||||
Message = $"Plugin jurisdiction '{manifest.Jurisdiction}' does not match required '{options.Jurisdiction}'"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check compliance filter
|
||||
if (!IsComplianceSupported(manifest, options.RequiredCompliance))
|
||||
{
|
||||
_logger?.LogDebug("Plugin {PluginId} skipped: compliance mismatch", manifest.Id);
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = manifest.Id,
|
||||
Category = PluginLoadErrorCategory.ComplianceMismatch,
|
||||
Message = "Plugin does not meet required compliance standards"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load runtime config
|
||||
PluginRuntimeConfig? config = null;
|
||||
var configPath = Path.Combine(pluginDir, "config.yaml");
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
config = await LoadConfigAsync(configPath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to load config for plugin {PluginId}", manifest.Id);
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = manifest.Id,
|
||||
Category = PluginLoadErrorCategory.ConfigInvalid,
|
||||
Message = $"Failed to load config: {ex.Message}",
|
||||
Exception = ex
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registry overrides
|
||||
config = ApplyRegistryOverrides(manifest.Id, config, registry);
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(manifest, config, null);
|
||||
|
||||
// Skip disabled plugins
|
||||
if (!loadedPlugin.IsEnabled)
|
||||
{
|
||||
_logger?.LogDebug("Plugin {PluginId} is disabled, skipping assembly load", manifest.Id);
|
||||
loadedPlugins.Add(loadedPlugin);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load assembly if requested
|
||||
if (options.LoadAssemblies)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = await LoadAssemblyAsync(manifest, pluginDir, options, cancellationToken);
|
||||
loadedPlugin.Assembly = assembly;
|
||||
_logger?.LogInformation("Loaded plugin {PluginId} from {Assembly}", manifest.Id, manifest.Assembly.Path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to load assembly for plugin {PluginId}", manifest.Id);
|
||||
errors.Add(new PluginLoadError
|
||||
{
|
||||
PluginId = manifest.Id,
|
||||
Category = PluginLoadErrorCategory.AssemblyLoadFailed,
|
||||
Message = $"Failed to load assembly: {ex.Message}",
|
||||
Exception = ex
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadedPlugins.Add(loadedPlugin);
|
||||
}
|
||||
|
||||
// Sort by priority (higher first)
|
||||
loadedPlugins.Sort((a, b) => b.EffectivePriority.CompareTo(a.EffectivePriority));
|
||||
|
||||
return new PluginRegistryResult
|
||||
{
|
||||
Registry = registry,
|
||||
RegistryPath = registryPath,
|
||||
PluginDirectories = pluginDirectories,
|
||||
LoadedPlugins = loadedPlugins,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a single plugin manifest from a JSON file.
|
||||
/// </summary>
|
||||
public async Task<PluginManifest?> LoadManifestAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
return JsonSerializer.Deserialize<PluginManifest>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a plugin runtime configuration from a YAML file.
|
||||
/// </summary>
|
||||
public async Task<PluginRuntimeConfig?> LoadConfigAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var expanded = ExpandEnvironmentVariables(yaml);
|
||||
return YamlDeserializer.Deserialize<PluginRuntimeConfig>(expanded);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DiscoverPluginDirectories(string baseDirectory)
|
||||
{
|
||||
if (!Directory.Exists(baseDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var directories = new List<string>();
|
||||
foreach (var dir in Directory.EnumerateDirectories(baseDirectory))
|
||||
{
|
||||
var dirName = Path.GetFileName(dir);
|
||||
|
||||
// Skip hidden directories and common non-plugin directories
|
||||
if (dirName.StartsWith('.') || dirName.Equals("bin", StringComparison.OrdinalIgnoreCase) ||
|
||||
dirName.Equals("obj", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A plugin directory should have either a plugin.json or config.yaml
|
||||
if (File.Exists(Path.Combine(dir, "plugin.json")) || File.Exists(Path.Combine(dir, "config.yaml")))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
private static bool IsPlatformSupported(PluginManifest manifest, string? currentPlatform)
|
||||
{
|
||||
// No platform filter or no platforms specified = supported
|
||||
if (string.IsNullOrEmpty(currentPlatform) || manifest.Platforms.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check exact match or wildcard
|
||||
foreach (var platform in manifest.Platforms)
|
||||
{
|
||||
if (platform.Equals("*", StringComparison.Ordinal) ||
|
||||
platform.Equals(currentPlatform, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check partial match (e.g., "linux" matches "linux-x64")
|
||||
if (currentPlatform.StartsWith(platform, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsJurisdictionSupported(PluginManifest manifest, string? requiredJurisdiction)
|
||||
{
|
||||
// No jurisdiction filter = supported
|
||||
if (string.IsNullOrEmpty(requiredJurisdiction))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// "world" jurisdiction is always supported
|
||||
if (manifest.Jurisdiction is null || manifest.Jurisdiction.Equals("world", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
return manifest.Jurisdiction.Equals(requiredJurisdiction, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsComplianceSupported(PluginManifest manifest, IReadOnlyList<string> requiredCompliance)
|
||||
{
|
||||
// No compliance filter = supported
|
||||
if (requiredCompliance.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plugin must declare at least one of the required compliance standards
|
||||
foreach (var required in requiredCompliance)
|
||||
{
|
||||
if (manifest.Compliance.Any(c => c.Equals(required, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private PluginRuntimeConfig? ApplyRegistryOverrides(string pluginId, PluginRuntimeConfig? config, PluginRegistry? registry)
|
||||
{
|
||||
if (registry?.Plugins is null || !registry.Plugins.TryGetValue(GetShortPluginId(pluginId), out var entry))
|
||||
{
|
||||
return config;
|
||||
}
|
||||
|
||||
// Create config if it doesn't exist
|
||||
config ??= new PluginRuntimeConfig { Id = pluginId };
|
||||
|
||||
// Apply overrides
|
||||
var newConfig = new Dictionary<string, object?>(config.Config);
|
||||
|
||||
return config with
|
||||
{
|
||||
Enabled = entry.Enabled ?? config.Enabled,
|
||||
Priority = entry.Priority ?? config.Priority
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetShortPluginId(string fullId)
|
||||
{
|
||||
// Extract short name from "stellaops.module.name" format
|
||||
var parts = fullId.Split('.');
|
||||
return parts.Length > 0 ? parts[^1] : fullId;
|
||||
}
|
||||
|
||||
private async Task<PluginAssembly?> LoadAssemblyAsync(
|
||||
PluginManifest manifest,
|
||||
string pluginDir,
|
||||
PluginRegistryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Determine assembly path
|
||||
var assemblyPath = manifest.Assembly.Path;
|
||||
|
||||
// First, check in the plugin config directory
|
||||
var fullPath = Path.IsPathRooted(assemblyPath)
|
||||
? assemblyPath
|
||||
: Path.Combine(pluginDir, assemblyPath);
|
||||
|
||||
// If not found there, check in the assembly output directory
|
||||
if (!File.Exists(fullPath) && options.AssemblyDirectory is not null)
|
||||
{
|
||||
var assemblyFileName = Path.GetFileName(assemblyPath);
|
||||
var altPath = Path.Combine(options.AssemblyDirectory, assemblyFileName);
|
||||
if (File.Exists(altPath))
|
||||
{
|
||||
fullPath = altPath;
|
||||
}
|
||||
|
||||
// Also check in a subdirectory matching the plugin short name
|
||||
var shortId = GetShortPluginId(manifest.Id);
|
||||
var subDirPath = Path.Combine(options.AssemblyDirectory, shortId, assemblyFileName);
|
||||
if (File.Exists(subDirPath))
|
||||
{
|
||||
fullPath = subDirPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Assembly not found: {fullPath}", fullPath);
|
||||
}
|
||||
|
||||
// Verify SHA256 if specified
|
||||
if (!string.IsNullOrEmpty(manifest.Assembly.Sha256))
|
||||
{
|
||||
var actualHash = await ComputeSha256Async(fullPath, cancellationToken);
|
||||
if (!actualHash.Equals(manifest.Assembly.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Assembly hash mismatch: expected {manifest.Assembly.Sha256}, got {actualHash}");
|
||||
}
|
||||
}
|
||||
|
||||
// Load the assembly
|
||||
var loadContext = new PluginLoadContext(fullPath);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(fullPath);
|
||||
|
||||
return new PluginAssembly(fullPath, assembly, loadContext);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands environment variable references in a string.
|
||||
/// Supports ${VAR} and ${VAR:-default} syntax.
|
||||
/// </summary>
|
||||
public static string ExpandEnvironmentVariables(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
// Match ${VAR} or ${VAR:-default}
|
||||
return Regex.Replace(input, @"\$\{([^}:]+)(?::-([^}]*))?\}", match =>
|
||||
{
|
||||
var varName = match.Groups[1].Value;
|
||||
var defaultValue = match.Groups[2].Success ? match.Groups[2].Value : string.Empty;
|
||||
var envValue = Environment.GetEnvironmentVariable(varName);
|
||||
return string.IsNullOrEmpty(envValue) ? defaultValue : envValue;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current platform identifier (e.g., "linux-x64", "win-x64", "osx-arm64").
|
||||
/// </summary>
|
||||
public static string GetCurrentPlatform()
|
||||
{
|
||||
var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" :
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" :
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "unknown";
|
||||
|
||||
var arch = RuntimeInformation.ProcessArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
Architecture.X86 => "x86",
|
||||
Architecture.Arm => "arm",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
return $"{os}-{arch}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for plugin manifest loading.
|
||||
/// </summary>
|
||||
public static class PluginManifestLoaderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads plugins from etc/plugins/{module} with assembly output from plugins/{module}.
|
||||
/// </summary>
|
||||
public static async Task<PluginRegistryResult> LoadModulePluginsAsync(
|
||||
this PluginManifestLoader loader,
|
||||
string repoRoot,
|
||||
string moduleName,
|
||||
ILogger? logger = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configDir = Path.Combine(repoRoot, "etc", "plugins", moduleName);
|
||||
var assemblyDir = Path.Combine(repoRoot, "plugins", moduleName);
|
||||
|
||||
var options = new PluginRegistryOptions
|
||||
{
|
||||
BaseDirectory = configDir,
|
||||
ModuleName = moduleName,
|
||||
AssemblyDirectory = Directory.Exists(assemblyDir) ? assemblyDir : null,
|
||||
Platform = PluginManifestLoader.GetCurrentPlatform()
|
||||
};
|
||||
|
||||
return await loader.LoadAsync(options, cancellationToken);
|
||||
}
|
||||
}
|
||||
287
src/__Libraries/StellaOps.Plugin/Manifest/PluginRegistry.cs
Normal file
287
src/__Libraries/StellaOps.Plugin/Manifest/PluginRegistry.cs
Normal file
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Plugin.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin registry schema for registry.yaml files.
|
||||
/// Each module has a registry that defines plugin defaults and overrides.
|
||||
/// </summary>
|
||||
public sealed record PluginRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry schema version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Module category (e.g., "router.plugins", "scanner.plugins").
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default settings for all plugins in this registry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("defaults")]
|
||||
public PluginRegistryDefaults? Defaults { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-plugin configuration entries keyed by plugin ID (short name).
|
||||
/// </summary>
|
||||
[JsonPropertyName("plugins")]
|
||||
public IReadOnlyDictionary<string, PluginRegistryEntry> Plugins { get; init; } = new Dictionary<string, PluginRegistryEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default settings applied to all plugins unless overridden.
|
||||
/// </summary>
|
||||
public sealed record PluginRegistryDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Default enabled state for all plugins.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for plugin operations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timeout")]
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default retry count for plugin operations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("retryCount")]
|
||||
public int RetryCount { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Default jurisdiction filter (null = no filter).
|
||||
/// </summary>
|
||||
[JsonPropertyName("jurisdiction")]
|
||||
public string? Jurisdiction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required compliance standards filter.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiredCompliance")]
|
||||
public IReadOnlyList<string> RequiredCompliance { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-plugin entry in the registry.
|
||||
/// </summary>
|
||||
public sealed record PluginRegistryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the plugin is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority for loading order (higher = first).
|
||||
/// </summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int? Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to plugin-specific config.yaml file (relative to registry).
|
||||
/// </summary>
|
||||
[JsonPropertyName("config")]
|
||||
public string? Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout override for this plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timeout")]
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional environment variables for this plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environment")]
|
||||
public IReadOnlyDictionary<string, string> Environment { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of loading a plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The loaded registry.
|
||||
/// </summary>
|
||||
public PluginRegistry? Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the registry file.
|
||||
/// </summary>
|
||||
public required string RegistryPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry was successfully loaded.
|
||||
/// </summary>
|
||||
public bool IsLoaded => Registry is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if loading failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discovered plugin directories.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PluginDirectories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Loaded plugin manifests.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LoadedPlugin> LoadedPlugins { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Plugins that failed to load.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PluginLoadError> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error that occurred during plugin loading.
|
||||
/// </summary>
|
||||
public sealed record PluginLoadError
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin ID or path that failed.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error category.
|
||||
/// </summary>
|
||||
public required PluginLoadErrorCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner exception if available.
|
||||
/// </summary>
|
||||
public Exception? Exception { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories of plugin load errors.
|
||||
/// </summary>
|
||||
public enum PluginLoadErrorCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin manifest not found.
|
||||
/// </summary>
|
||||
ManifestNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin manifest is invalid.
|
||||
/// </summary>
|
||||
ManifestInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin config is invalid.
|
||||
/// </summary>
|
||||
ConfigInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin assembly not found.
|
||||
/// </summary>
|
||||
AssemblyNotFound,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin assembly failed to load.
|
||||
/// </summary>
|
||||
AssemblyLoadFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin failed signature verification.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is incompatible with host version.
|
||||
/// </summary>
|
||||
IncompatibleVersion,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin dependencies are missing.
|
||||
/// </summary>
|
||||
MissingDependencies,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is filtered out by platform.
|
||||
/// </summary>
|
||||
PlatformMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is filtered out by jurisdiction.
|
||||
/// </summary>
|
||||
JurisdictionMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is filtered out by compliance.
|
||||
/// </summary>
|
||||
ComplianceMismatch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for loading a plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base directory for plugin discovery (e.g., etc/plugins/{module}).
|
||||
/// </summary>
|
||||
public required string BaseDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Module name (e.g., "router", "scanner", "excititor").
|
||||
/// </summary>
|
||||
public required string ModuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory containing built plugin assemblies (e.g., plugins/{module}).
|
||||
/// </summary>
|
||||
public string? AssemblyDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform filter (e.g., "linux-x64", "win-x64").
|
||||
/// </summary>
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Jurisdiction filter (e.g., "russia", "china", "eu").
|
||||
/// </summary>
|
||||
public string? Jurisdiction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required compliance standards filter.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RequiredCompliance { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to load plugin assemblies.
|
||||
/// </summary>
|
||||
public bool LoadAssemblies { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce signature verification.
|
||||
/// </summary>
|
||||
public bool EnforceSignatures { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Host version for compatibility checking.
|
||||
/// </summary>
|
||||
public Version? HostVersion { get; init; }
|
||||
}
|
||||
@@ -14,9 +14,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user