release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
namespace StellaOps.Plugin.Samples.HelloWorld.Tests;
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Testing;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for HelloWorldPlugin demonstrating the Plugin.Testing library usage.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HelloWorldPluginTests : PluginTestBase<HelloWorldPlugin>
|
||||
{
|
||||
protected override Dictionary<string, object> GetConfiguration() => new()
|
||||
{
|
||||
["Greeting"] = "Hello",
|
||||
["LogGreetings"] = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Info_ReturnsCorrectPluginInfo()
|
||||
{
|
||||
// Assert - Plugin is already initialized by base class
|
||||
Plugin.Info.Id.Should().Be("org.stellaops.samples.helloworld");
|
||||
Plugin.Info.Name.Should().Be("Hello World Sample");
|
||||
Plugin.Info.Version.Should().Be("1.0.0");
|
||||
Plugin.Info.Vendor.Should().Be("Stella Ops");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capabilities_ReturnsNone()
|
||||
{
|
||||
// Assert - this is a sample plugin with no special capabilities
|
||||
Plugin.Capabilities.Should().Be(PluginCapabilities.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_AfterInitialization_IsActive()
|
||||
{
|
||||
// Assert - base class initializes the plugin
|
||||
Plugin.State.Should().Be(StellaOps.Plugin.Abstractions.Lifecycle.PluginLifecycleState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Greet_WithName_ReturnsGreeting()
|
||||
{
|
||||
// Act
|
||||
var result = Plugin.Greet("World");
|
||||
|
||||
// Assert
|
||||
result.Should().Be("Hello, World!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_WhenInitialized_ReturnsHealthy()
|
||||
{
|
||||
// Act
|
||||
var health = await Plugin.HealthCheckAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
health.Status.Should().Be(HealthStatus.Healthy);
|
||||
health.Details.Should().ContainKey("greeting");
|
||||
health.Details!["greeting"].Should().Be("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Greet_LogsMessage()
|
||||
{
|
||||
// Act
|
||||
Plugin.Greet("Test");
|
||||
|
||||
// Assert
|
||||
Logger.HasLoggedContaining("Generated greeting").Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that require custom configuration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HelloWorldPluginCustomConfigTests : PluginTestBase<HelloWorldPlugin>
|
||||
{
|
||||
protected override Dictionary<string, object> GetConfiguration() => new()
|
||||
{
|
||||
["Greeting"] = "Howdy",
|
||||
["LogGreetings"] = false
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Greet_WithCustomGreeting_UsesConfiguredGreeting()
|
||||
{
|
||||
// Act
|
||||
var result = Plugin.Greet("Partner");
|
||||
|
||||
// Assert
|
||||
result.Should().Be("Howdy, Partner!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Greet_WhenLoggingDisabled_DoesNotLog()
|
||||
{
|
||||
// Arrange
|
||||
Logger.Clear();
|
||||
|
||||
// Act
|
||||
Plugin.Greet("Test");
|
||||
|
||||
// Assert - should not have logged the greeting
|
||||
Logger.HasLoggedContaining("Generated greeting").Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests with deterministic time.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HelloWorldPluginTimeTests : PluginTestBase<HelloWorldPlugin>
|
||||
{
|
||||
private static readonly DateTimeOffset FixedStartTime = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
protected override void ConfigureHost(PluginTestHostOptions options)
|
||||
{
|
||||
// Set a fixed start time for deterministic testing
|
||||
options.StartTime = FixedStartTime;
|
||||
}
|
||||
|
||||
protected override Dictionary<string, object> GetConfiguration() => new()
|
||||
{
|
||||
["Greeting"] = "Hello"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_ReturnsInitializedTime()
|
||||
{
|
||||
// Act
|
||||
var health = await Plugin.HealthCheckAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
health.Status.Should().Be(HealthStatus.Healthy);
|
||||
health.Details.Should().ContainKey("initialized_at");
|
||||
// The initialized time should contain our fixed date
|
||||
health.Details!["initialized_at"].ToString().Should().Contain("2025-01-15");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FakeTimeProvider_CanAdvanceTime()
|
||||
{
|
||||
// Arrange
|
||||
var initialTime = FakeTimeProvider?.GetUtcNow();
|
||||
|
||||
// Act
|
||||
FakeTimeProvider?.AdvanceMinutes(5);
|
||||
|
||||
// Assert
|
||||
var newTime = FakeTimeProvider?.GetUtcNow();
|
||||
newTime.Should().Be(initialTime?.AddMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for uninitialized plugin behavior.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HelloWorldPluginUninitializedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_WhenNotInitialized_ReturnsUnhealthy()
|
||||
{
|
||||
// Arrange - create plugin without initializing
|
||||
var plugin = new HelloWorldPlugin();
|
||||
|
||||
// Act
|
||||
var health = await plugin.HealthCheckAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
health.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
health.Message.Should().Contain("not initialized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Opt out of Concelier test infrastructure -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Test packages are added by Directory.Build.props for *.Tests projects -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Samples.HelloWorld\StellaOps.Plugin.Samples.HelloWorld.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin.Testing\StellaOps.Plugin.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Plugin.Samples.HelloWorld;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Sdk;
|
||||
|
||||
/// <summary>
|
||||
/// Sample HelloWorld plugin demonstrating SDK usage patterns.
|
||||
/// This plugin shows how to:
|
||||
/// - Use PluginBase for simplified development
|
||||
/// - Access configuration values
|
||||
/// - Use structured logging
|
||||
/// - Handle lifecycle events
|
||||
/// - Report health status
|
||||
/// </summary>
|
||||
public sealed class HelloWorldPlugin : PluginBase
|
||||
{
|
||||
private HelloWorldOptions? _options;
|
||||
private DateTimeOffset _initializedAt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginInfo Info { get; } = new PluginInfoBuilder()
|
||||
.WithId("org.stellaops.samples.helloworld")
|
||||
.WithName("Hello World Sample")
|
||||
.WithVersion("1.0.0")
|
||||
.WithVendor("Stella Ops")
|
||||
.WithDescription("A sample plugin demonstrating SDK usage patterns")
|
||||
.WithLicense("AGPL-3.0-or-later")
|
||||
.Build();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PluginCapabilities Capabilities => PluginCapabilities.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
// Load and validate configuration
|
||||
_options = Configuration.Bind<HelloWorldOptions>();
|
||||
|
||||
Logger.Info("HelloWorld plugin initializing with greeting: {Greeting}", _options.Greeting);
|
||||
|
||||
// Demonstrate async initialization (e.g., loading resources)
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
_initializedAt = TimeProvider.GetUtcNow();
|
||||
Logger.Debug("HelloWorld plugin initialized successfully at {Time}", _initializedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask OnDisposeAsync()
|
||||
{
|
||||
Logger.Debug("HelloWorld plugin disposed");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
// Example health check logic
|
||||
if (_options is null)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Plugin not initialized"));
|
||||
}
|
||||
|
||||
return Task.FromResult(
|
||||
HealthCheckResult.Healthy()
|
||||
.WithDetails(new Dictionary<string, object>
|
||||
{
|
||||
["greeting"] = _options.Greeting,
|
||||
["initialized_at"] = _initializedAt.ToString("O")
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demonstrates the greeting functionality.
|
||||
/// </summary>
|
||||
/// <param name="name">Name to greet.</param>
|
||||
/// <returns>The greeting message.</returns>
|
||||
public string Greet(string name)
|
||||
{
|
||||
var greeting = _options?.Greeting ?? "Hello";
|
||||
var message = $"{greeting}, {name}!";
|
||||
|
||||
if (_options?.LogGreetings == true)
|
||||
{
|
||||
Logger.Info("Generated greeting for {Name}: {Message}", name, message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for HelloWorld plugin.
|
||||
/// </summary>
|
||||
public sealed class HelloWorldOptions : PluginOptionsBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the greeting phrase.
|
||||
/// </summary>
|
||||
[PluginConfig(Key = "Greeting", DefaultValue = "Hello", Description = "The greeting phrase to use")]
|
||||
public string Greeting { get; set; } = "Hello";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to log greetings.
|
||||
/// </summary>
|
||||
[PluginConfig(Key = "LogGreetings", DefaultValue = true, Description = "Whether to log each greeting")]
|
||||
public bool LogGreetings { get; set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Greeting))
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Greeting cannot be empty",
|
||||
new[] { nameof(Greeting) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Sample HelloWorld plugin demonstrating SDK usage</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,185 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class as a Stella Ops plugin with essential metadata.
|
||||
/// Used for assembly scanning and plugin discovery.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Reverse domain notation plugin ID.
|
||||
/// Example: "com.stellaops.crypto.gost"
|
||||
/// </summary>
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic version string.
|
||||
/// Example: "1.0.0" or "1.0.0-beta.1"
|
||||
/// </summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin vendor/author.
|
||||
/// </summary>
|
||||
public string Vendor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SPDX license identifier.
|
||||
/// Example: "MIT", "Apache-2.0", "AGPL-3.0-or-later"
|
||||
/// </summary>
|
||||
public string? LicenseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin attribute.
|
||||
/// </summary>
|
||||
/// <param name="id">Reverse domain notation plugin ID.</param>
|
||||
/// <param name="name">Human-readable display name.</param>
|
||||
/// <param name="version">Semantic version string.</param>
|
||||
/// <param name="vendor">Plugin vendor/author.</param>
|
||||
public PluginAttribute(string id, string name, string version, string vendor)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Version = version;
|
||||
Vendor = vendor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts to <see cref="PluginInfo"/>.
|
||||
/// </summary>
|
||||
public PluginInfo ToPluginInfo() => new(
|
||||
Id: Id,
|
||||
Name: Name,
|
||||
Version: Version,
|
||||
Vendor: Vendor,
|
||||
Description: Description,
|
||||
LicenseId: LicenseId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares a capability provided by a plugin.
|
||||
/// Multiple attributes can be applied to declare multiple capabilities.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class ProvidesCapabilityAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The capability flags this plugin provides.
|
||||
/// </summary>
|
||||
public PluginCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional specific capability identifier.
|
||||
/// Example: "gost" for crypto capability, "github" for SCM capability.
|
||||
/// </summary>
|
||||
public string? CapabilityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new provides capability attribute.
|
||||
/// </summary>
|
||||
/// <param name="capabilities">The capabilities provided.</param>
|
||||
public ProvidesCapabilityAttribute(PluginCapabilities capabilities)
|
||||
{
|
||||
Capabilities = capabilities;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares a dependency on another plugin.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class RequiresPluginAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the required plugin.
|
||||
/// </summary>
|
||||
public string PluginId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional version constraint.
|
||||
/// </summary>
|
||||
public string? VersionConstraint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the dependency is optional.
|
||||
/// </summary>
|
||||
public bool Optional { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new requires plugin attribute.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the required plugin.</param>
|
||||
public RequiresPluginAttribute(string pluginId)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares a permission requirement for the plugin.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class RequiresPermissionAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The permission identifier.
|
||||
/// Example: "filesystem.read", "network.outbound", "secrets.read"
|
||||
/// </summary>
|
||||
public string Permission { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason the permission is needed.
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new requires permission attribute.
|
||||
/// </summary>
|
||||
/// <param name="permission">The permission identifier.</param>
|
||||
public RequiresPermissionAttribute(string permission)
|
||||
{
|
||||
Permission = permission;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies minimum and/or maximum platform version compatibility.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PlatformCompatibilityAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum platform version required.
|
||||
/// </summary>
|
||||
public string? MinVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum platform version supported.
|
||||
/// </summary>
|
||||
public string? MaxVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new platform compatibility attribute.
|
||||
/// </summary>
|
||||
public PlatformCompatibilityAttribute() { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new platform compatibility attribute with minimum version.
|
||||
/// </summary>
|
||||
/// <param name="minVersion">Minimum version required.</param>
|
||||
public PlatformCompatibilityAttribute(string minVersion)
|
||||
{
|
||||
MinVersion = minVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for source code and binary analysis.
|
||||
/// Implemented by scanner analyzers for different languages/ecosystems.
|
||||
/// </summary>
|
||||
public interface IAnalysisCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Analysis type identifier, e.g., "maven", "npm", "go-mod", "dotnet".
|
||||
/// </summary>
|
||||
string AnalysisType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// File patterns this analyzer can process.
|
||||
/// Glob patterns, e.g., ["pom.xml", "**/pom.xml", "*.jar"].
|
||||
/// </summary>
|
||||
IReadOnlyList<string> FilePatterns { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Languages/ecosystems this analyzer supports.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedEcosystems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this analyzer can process the given file.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the file.</param>
|
||||
/// <param name="fileHeader">First few bytes of the file for magic number detection.</param>
|
||||
/// <returns>True if this analyzer can handle the file.</returns>
|
||||
bool CanAnalyze(string filePath, ReadOnlySpan<byte> fileHeader);
|
||||
|
||||
/// <summary>
|
||||
/// Analyze a file or directory and extract dependency information.
|
||||
/// </summary>
|
||||
/// <param name="context">Analysis context with file access and configuration.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Analysis result with discovered components.</returns>
|
||||
Task<AnalysisResult> AnalyzeAsync(IAnalysisContext context, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context provided for analysis operations.
|
||||
/// </summary>
|
||||
public interface IAnalysisContext
|
||||
{
|
||||
/// <summary>Root path being analyzed.</summary>
|
||||
string RootPath { get; }
|
||||
|
||||
/// <summary>Target file or directory for analysis.</summary>
|
||||
string TargetPath { get; }
|
||||
|
||||
/// <summary>Read file contents.</summary>
|
||||
/// <param name="relativePath">Path relative to root.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>File contents as bytes.</returns>
|
||||
Task<byte[]> ReadFileAsync(string relativePath, CancellationToken ct);
|
||||
|
||||
/// <summary>List files matching a pattern.</summary>
|
||||
/// <param name="pattern">Glob pattern.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matching file paths.</returns>
|
||||
Task<IReadOnlyList<string>> GlobAsync(string pattern, CancellationToken ct);
|
||||
|
||||
/// <summary>Check if file exists.</summary>
|
||||
/// <param name="relativePath">Path relative to root.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if file exists.</returns>
|
||||
Task<bool> FileExistsAsync(string relativePath, CancellationToken ct);
|
||||
|
||||
/// <summary>Analysis configuration.</summary>
|
||||
IPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>Logger for diagnostics.</summary>
|
||||
IPluginLogger Logger { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an analysis operation.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether analysis completed successfully.</param>
|
||||
/// <param name="Components">Discovered components.</param>
|
||||
/// <param name="Diagnostics">Diagnostic messages from analysis.</param>
|
||||
/// <param name="Metadata">Analysis metadata.</param>
|
||||
public sealed record AnalysisResult(
|
||||
bool Success,
|
||||
IReadOnlyList<DiscoveredComponent> Components,
|
||||
IReadOnlyList<AnalysisDiagnostic> Diagnostics,
|
||||
AnalysisMetadata Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// A component discovered during analysis.
|
||||
/// </summary>
|
||||
/// <param name="Name">Component name.</param>
|
||||
/// <param name="Version">Component version.</param>
|
||||
/// <param name="Ecosystem">Ecosystem (e.g., "maven", "npm").</param>
|
||||
/// <param name="Purl">Package URL, if applicable.</param>
|
||||
/// <param name="Cpe">CPE identifier, if applicable.</param>
|
||||
/// <param name="Type">Component type.</param>
|
||||
/// <param name="License">License identifier or expression.</param>
|
||||
/// <param name="Dependencies">Dependencies of this component.</param>
|
||||
/// <param name="Metadata">Additional metadata.</param>
|
||||
public sealed record DiscoveredComponent(
|
||||
string Name,
|
||||
string Version,
|
||||
string Ecosystem,
|
||||
string? Purl,
|
||||
string? Cpe,
|
||||
ComponentType Type,
|
||||
string? License,
|
||||
IReadOnlyList<ComponentDependency> Dependencies,
|
||||
IReadOnlyDictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// A dependency relationship between components.
|
||||
/// </summary>
|
||||
/// <param name="Name">Dependency name.</param>
|
||||
/// <param name="VersionConstraint">Version constraint expression.</param>
|
||||
/// <param name="Scope">Dependency scope.</param>
|
||||
/// <param name="IsOptional">Whether the dependency is optional.</param>
|
||||
public sealed record ComponentDependency(
|
||||
string Name,
|
||||
string? VersionConstraint,
|
||||
DependencyScope Scope,
|
||||
bool IsOptional);
|
||||
|
||||
/// <summary>
|
||||
/// A diagnostic message from analysis.
|
||||
/// </summary>
|
||||
/// <param name="Severity">Diagnostic severity.</param>
|
||||
/// <param name="Code">Diagnostic code.</param>
|
||||
/// <param name="Message">Human-readable message.</param>
|
||||
/// <param name="FilePath">File path, if applicable.</param>
|
||||
/// <param name="Line">Line number, if applicable.</param>
|
||||
public sealed record AnalysisDiagnostic(
|
||||
DiagnosticSeverity Severity,
|
||||
string Code,
|
||||
string Message,
|
||||
string? FilePath = null,
|
||||
int? Line = null);
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the analysis operation.
|
||||
/// </summary>
|
||||
/// <param name="AnalyzerType">Type of analyzer used.</param>
|
||||
/// <param name="AnalyzerVersion">Version of the analyzer.</param>
|
||||
/// <param name="Duration">Time taken for analysis.</param>
|
||||
/// <param name="FilesProcessed">Number of files processed.</param>
|
||||
public sealed record AnalysisMetadata(
|
||||
string AnalyzerType,
|
||||
string AnalyzerVersion,
|
||||
TimeSpan Duration,
|
||||
int FilesProcessed);
|
||||
|
||||
/// <summary>
|
||||
/// Types of components that can be discovered.
|
||||
/// </summary>
|
||||
public enum ComponentType
|
||||
{
|
||||
/// <summary>A software library.</summary>
|
||||
Library,
|
||||
|
||||
/// <summary>A framework.</summary>
|
||||
Framework,
|
||||
|
||||
/// <summary>An application.</summary>
|
||||
Application,
|
||||
|
||||
/// <summary>An operating system.</summary>
|
||||
OperatingSystem,
|
||||
|
||||
/// <summary>A hardware device.</summary>
|
||||
Device,
|
||||
|
||||
/// <summary>A container image.</summary>
|
||||
Container,
|
||||
|
||||
/// <summary>A file.</summary>
|
||||
File
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope of a dependency.
|
||||
/// </summary>
|
||||
public enum DependencyScope
|
||||
{
|
||||
/// <summary>Required at runtime.</summary>
|
||||
Runtime,
|
||||
|
||||
/// <summary>Required for development only.</summary>
|
||||
Development,
|
||||
|
||||
/// <summary>Required for testing only.</summary>
|
||||
Test,
|
||||
|
||||
/// <summary>Required at build time only.</summary>
|
||||
Build,
|
||||
|
||||
/// <summary>Optional dependency.</summary>
|
||||
Optional,
|
||||
|
||||
/// <summary>Provided by the runtime environment.</summary>
|
||||
Provided
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for diagnostics.
|
||||
/// </summary>
|
||||
public enum DiagnosticSeverity
|
||||
{
|
||||
/// <summary>Informational message.</summary>
|
||||
Info,
|
||||
|
||||
/// <summary>Warning that should be reviewed.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Error that may affect results.</summary>
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for authentication and authorization plugins.
|
||||
/// Plugins implementing this interface can authenticate users, validate tokens,
|
||||
/// and provide user/group information.
|
||||
/// </summary>
|
||||
public interface IAuthCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the authentication provider type (e.g., "ldap", "oidc", "saml", "workforce").
|
||||
/// </summary>
|
||||
string ProviderType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of supported authentication methods (e.g., "password", "kerberos", "sso").
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedMethods { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user with the provided credentials.
|
||||
/// </summary>
|
||||
/// <param name="request">Authentication request containing credentials.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Authentication result indicating success or failure.</returns>
|
||||
Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates an existing token or session.
|
||||
/// </summary>
|
||||
/// <param name="token">Token to validate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Validation result with token claims if valid.</returns>
|
||||
Task<TokenValidationResult> ValidateTokenAsync(string token, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves user information by user ID.
|
||||
/// </summary>
|
||||
/// <param name="userId">User identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>User information or null if not found.</returns>
|
||||
Task<AuthUserInfo?> GetUserInfoAsync(string userId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves group memberships for a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of groups the user belongs to.</returns>
|
||||
Task<IReadOnlyList<AuthGroupInfo>> GetUserGroupsAsync(string userId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user has a specific permission.
|
||||
/// </summary>
|
||||
/// <param name="userId">User identifier.</param>
|
||||
/// <param name="permission">Permission to check.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if user has the permission.</returns>
|
||||
Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Initiates an SSO authentication flow (for OIDC/SAML providers).
|
||||
/// </summary>
|
||||
/// <param name="request">SSO initiation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>SSO initiation data including authorization URL, or null if not supported.</returns>
|
||||
Task<SsoInitiation?> InitiateSsoAsync(SsoRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Completes an SSO authentication flow after callback.
|
||||
/// </summary>
|
||||
/// <param name="callback">SSO callback data.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Authentication result.</returns>
|
||||
Task<AuthResult> CompleteSsoAsync(SsoCallback callback, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentication request containing credentials.
|
||||
/// </summary>
|
||||
/// <param name="Method">Authentication method (e.g., "password", "kerberos", "token").</param>
|
||||
/// <param name="Username">Username for password-based auth.</param>
|
||||
/// <param name="Password">Password for password-based auth.</param>
|
||||
/// <param name="Token">Token for token-based auth.</param>
|
||||
/// <param name="AdditionalData">Additional authentication data.</param>
|
||||
public sealed record AuthRequest(
|
||||
string Method,
|
||||
string? Username,
|
||||
string? Password,
|
||||
string? Token,
|
||||
IReadOnlyDictionary<string, string>? AdditionalData);
|
||||
|
||||
/// <summary>
|
||||
/// Result of an authentication attempt.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether authentication succeeded.</param>
|
||||
/// <param name="UserId">Authenticated user ID.</param>
|
||||
/// <param name="AccessToken">Access token if issued.</param>
|
||||
/// <param name="RefreshToken">Refresh token if issued.</param>
|
||||
/// <param name="ExpiresAt">Token expiration time.</param>
|
||||
/// <param name="Roles">User roles.</param>
|
||||
/// <param name="Error">Error message if failed.</param>
|
||||
public sealed record AuthResult(
|
||||
bool Success,
|
||||
string? UserId,
|
||||
string? AccessToken,
|
||||
string? RefreshToken,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyList<string>? Roles,
|
||||
string? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful authentication result.
|
||||
/// </summary>
|
||||
public static AuthResult Succeeded(
|
||||
string userId,
|
||||
string? accessToken = null,
|
||||
string? refreshToken = null,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyList<string>? roles = null)
|
||||
=> new(true, userId, accessToken, refreshToken, expiresAt, roles, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed authentication result.
|
||||
/// </summary>
|
||||
public static AuthResult Failed(string error)
|
||||
=> new(false, null, null, null, null, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of token validation.
|
||||
/// </summary>
|
||||
/// <param name="Valid">Whether the token is valid.</param>
|
||||
/// <param name="UserId">User ID from token.</param>
|
||||
/// <param name="ExpiresAt">Token expiration time.</param>
|
||||
/// <param name="Claims">Token claims.</param>
|
||||
/// <param name="Error">Error message if invalid.</param>
|
||||
public sealed record TokenValidationResult(
|
||||
bool Valid,
|
||||
string? UserId,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyDictionary<string, string>? Claims,
|
||||
string? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a valid token result.
|
||||
/// </summary>
|
||||
public static TokenValidationResult Validated(
|
||||
string userId,
|
||||
DateTimeOffset? expiresAt = null,
|
||||
IReadOnlyDictionary<string, string>? claims = null)
|
||||
=> new(true, userId, expiresAt, claims, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invalid token result.
|
||||
/// </summary>
|
||||
public static TokenValidationResult Invalid(string error)
|
||||
=> new(false, null, null, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User information from auth provider.
|
||||
/// </summary>
|
||||
/// <param name="Id">User identifier.</param>
|
||||
/// <param name="Username">Username.</param>
|
||||
/// <param name="Email">Email address.</param>
|
||||
/// <param name="DisplayName">Display name.</param>
|
||||
/// <param name="Attributes">Additional user attributes.</param>
|
||||
public sealed record AuthUserInfo(
|
||||
string Id,
|
||||
string Username,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
IReadOnlyDictionary<string, string>? Attributes);
|
||||
|
||||
/// <summary>
|
||||
/// Group information from auth provider.
|
||||
/// </summary>
|
||||
/// <param name="Id">Group identifier.</param>
|
||||
/// <param name="Name">Group name.</param>
|
||||
/// <param name="Description">Group description.</param>
|
||||
public sealed record AuthGroupInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// SSO initiation request.
|
||||
/// </summary>
|
||||
/// <param name="RedirectUri">URI to redirect after authentication.</param>
|
||||
/// <param name="State">Optional state parameter.</param>
|
||||
/// <param name="Scopes">Requested scopes.</param>
|
||||
public sealed record SsoRequest(
|
||||
string RedirectUri,
|
||||
string? State,
|
||||
IReadOnlyList<string>? Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// SSO initiation response.
|
||||
/// </summary>
|
||||
/// <param name="AuthorizationUrl">URL to redirect user for authentication.</param>
|
||||
/// <param name="State">State parameter for CSRF protection.</param>
|
||||
/// <param name="CodeVerifier">PKCE code verifier for OAuth.</param>
|
||||
public sealed record SsoInitiation(
|
||||
string AuthorizationUrl,
|
||||
string State,
|
||||
string? CodeVerifier);
|
||||
|
||||
/// <summary>
|
||||
/// SSO callback data from identity provider.
|
||||
/// </summary>
|
||||
/// <param name="Code">Authorization code.</param>
|
||||
/// <param name="State">State parameter.</param>
|
||||
/// <param name="Error">Error if authentication failed.</param>
|
||||
/// <param name="CodeVerifier">PKCE code verifier.</param>
|
||||
public sealed record SsoCallback(
|
||||
string? Code,
|
||||
string? State,
|
||||
string? Error,
|
||||
string? CodeVerifier);
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Base capability for external system connectors.
|
||||
/// </summary>
|
||||
public interface IConnectorCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Connector type identifier, e.g., "scm.github", "registry.ecr", "vault.hashicorp".
|
||||
/// </summary>
|
||||
string ConnectorType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Test the connection to the external system.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result of the connection test.</returns>
|
||||
Task<ConnectionTestResult> TestConnectionAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get current connection status and metadata.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Connection information.</returns>
|
||||
Task<ConnectionInfo> GetConnectionInfoAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a connection test.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the connection was successful.</param>
|
||||
/// <param name="Message">Optional message describing the result.</param>
|
||||
/// <param name="Latency">Round-trip latency if measured.</param>
|
||||
/// <param name="Details">Additional diagnostic details.</param>
|
||||
public sealed record ConnectionTestResult(
|
||||
bool Success,
|
||||
string? Message = null,
|
||||
TimeSpan? Latency = null,
|
||||
IReadOnlyDictionary<string, object>? Details = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful connection test result.
|
||||
/// </summary>
|
||||
/// <param name="latency">Optional measured latency.</param>
|
||||
/// <returns>A success result.</returns>
|
||||
public static ConnectionTestResult Succeeded(TimeSpan? latency = null) =>
|
||||
new(true, "Connection successful", latency);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed connection test result.
|
||||
/// </summary>
|
||||
/// <param name="message">Description of the failure.</param>
|
||||
/// <param name="ex">Optional exception.</param>
|
||||
/// <returns>A failure result.</returns>
|
||||
public static ConnectionTestResult Failed(string message, Exception? ex = null) =>
|
||||
new(false, message, Details: ex != null ? new Dictionary<string, object>
|
||||
{
|
||||
["exception"] = ex.GetType().Name,
|
||||
["exceptionMessage"] = ex.Message
|
||||
} : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the current connection state.
|
||||
/// </summary>
|
||||
/// <param name="EndpointUrl">The endpoint URL.</param>
|
||||
/// <param name="AuthenticatedAs">The authenticated identity, if any.</param>
|
||||
/// <param name="ConnectedSince">When the connection was established.</param>
|
||||
/// <param name="Metadata">Additional connection metadata.</param>
|
||||
public sealed record ConnectionInfo(
|
||||
string EndpointUrl,
|
||||
string? AuthenticatedAs = null,
|
||||
DateTimeOffset? ConnectedSince = null,
|
||||
IReadOnlyDictionary<string, object>? Metadata = null);
|
||||
@@ -0,0 +1,141 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for cryptographic operations.
|
||||
/// Implemented by plugins providing signing, verification, encryption, or hashing.
|
||||
/// </summary>
|
||||
public interface ICryptoCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Algorithms supported by this provider.
|
||||
/// Format: "{family}-{variant}" e.g., "RSA-SHA256", "ECDSA-P256", "GOST-R34.10-2012".
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedAlgorithms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this provider can perform the specified operation with the given algorithm.
|
||||
/// </summary>
|
||||
/// <param name="operation">The cryptographic operation.</param>
|
||||
/// <param name="algorithm">The algorithm identifier.</param>
|
||||
/// <returns>True if the operation is supported.</returns>
|
||||
bool CanHandle(CryptoOperation operation, string algorithm);
|
||||
|
||||
/// <summary>
|
||||
/// Sign data using the specified algorithm and key.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to sign.</param>
|
||||
/// <param name="options">Signing options including algorithm and key reference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signature bytes.</returns>
|
||||
Task<byte[]> SignAsync(ReadOnlyMemory<byte> data, CryptoSignOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature.
|
||||
/// </summary>
|
||||
/// <param name="data">Original data.</param>
|
||||
/// <param name="signature">Signature to verify.</param>
|
||||
/// <param name="options">Verification options including algorithm and key reference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
Task<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CryptoVerifyOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Encrypt data.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to encrypt.</param>
|
||||
/// <param name="options">Encryption options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Encrypted data.</returns>
|
||||
Task<byte[]> EncryptAsync(ReadOnlyMemory<byte> data, CryptoEncryptOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Decrypt data.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to decrypt.</param>
|
||||
/// <param name="options">Decryption options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Decrypted data.</returns>
|
||||
Task<byte[]> DecryptAsync(ReadOnlyMemory<byte> data, CryptoDecryptOptions options, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Compute hash of data.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to hash.</param>
|
||||
/// <param name="algorithm">Hash algorithm.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Hash bytes.</returns>
|
||||
Task<byte[]> HashAsync(ReadOnlyMemory<byte> data, string algorithm, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic operations that can be performed.
|
||||
/// </summary>
|
||||
public enum CryptoOperation
|
||||
{
|
||||
/// <summary>Digital signature creation.</summary>
|
||||
Sign,
|
||||
|
||||
/// <summary>Digital signature verification.</summary>
|
||||
Verify,
|
||||
|
||||
/// <summary>Data encryption.</summary>
|
||||
Encrypt,
|
||||
|
||||
/// <summary>Data decryption.</summary>
|
||||
Decrypt,
|
||||
|
||||
/// <summary>Hash computation.</summary>
|
||||
Hash
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for cryptographic signing.
|
||||
/// </summary>
|
||||
/// <param name="Algorithm">The signing algorithm to use.</param>
|
||||
/// <param name="KeyId">Reference to the signing key.</param>
|
||||
/// <param name="KeyVersion">Optional key version.</param>
|
||||
/// <param name="Metadata">Optional metadata to include in signature.</param>
|
||||
public sealed record CryptoSignOptions(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string? KeyVersion = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for signature verification.
|
||||
/// </summary>
|
||||
/// <param name="Algorithm">The algorithm used for signing.</param>
|
||||
/// <param name="KeyId">Reference to the verification key.</param>
|
||||
/// <param name="KeyVersion">Optional key version.</param>
|
||||
/// <param name="CertificateChain">Optional certificate chain for verification.</param>
|
||||
public sealed record CryptoVerifyOptions(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
string? KeyVersion = null,
|
||||
string? CertificateChain = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for data encryption.
|
||||
/// </summary>
|
||||
/// <param name="Algorithm">The encryption algorithm to use.</param>
|
||||
/// <param name="KeyId">Reference to the encryption key.</param>
|
||||
/// <param name="Iv">Optional initialization vector.</param>
|
||||
/// <param name="Aad">Optional additional authenticated data.</param>
|
||||
public sealed record CryptoEncryptOptions(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
byte[]? Iv = null,
|
||||
byte[]? Aad = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for data decryption.
|
||||
/// </summary>
|
||||
/// <param name="Algorithm">The decryption algorithm to use.</param>
|
||||
/// <param name="KeyId">Reference to the decryption key.</param>
|
||||
/// <param name="Iv">Optional initialization vector.</param>
|
||||
/// <param name="Aad">Optional additional authenticated data.</param>
|
||||
public sealed record CryptoDecryptOptions(
|
||||
string Algorithm,
|
||||
string KeyId,
|
||||
byte[]? Iv = null,
|
||||
byte[]? Aad = null);
|
||||
@@ -0,0 +1,197 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for vulnerability feed connectors.
|
||||
/// Implemented by plugins that ingest vulnerability data from various sources.
|
||||
/// </summary>
|
||||
public interface IFeedCapability : IConnectorCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Feed identifier, e.g., "nvd", "osv", "github-advisory", "redhat-oval".
|
||||
/// </summary>
|
||||
string FeedId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed type category.
|
||||
/// </summary>
|
||||
FeedType FeedType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystems covered by this feed.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> CoveredEcosystems { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Get feed metadata including last update time and entry count.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Feed metadata.</returns>
|
||||
Task<FeedMetadata> GetMetadataAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch vulnerabilities modified since a given timestamp.
|
||||
/// </summary>
|
||||
/// <param name="since">Fetch vulnerabilities modified after this time.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of vulnerability entries.</returns>
|
||||
IAsyncEnumerable<FeedEntry> FetchEntriesAsync(DateTimeOffset? since, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific vulnerability by its CVE or feed-specific ID.
|
||||
/// </summary>
|
||||
/// <param name="id">Vulnerability identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The vulnerability entry, or null if not found.</returns>
|
||||
Task<FeedEntry?> GetEntryAsync(string id, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get the raw feed data format for debugging/logging.
|
||||
/// </summary>
|
||||
/// <param name="id">Vulnerability identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Raw JSON string of the feed entry.</returns>
|
||||
Task<string?> GetRawEntryAsync(string id, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of vulnerability feeds.
|
||||
/// </summary>
|
||||
public enum FeedType
|
||||
{
|
||||
/// <summary>Aggregated vulnerability database (NVD, OSV).</summary>
|
||||
Database,
|
||||
|
||||
/// <summary>Vendor-specific OVAL definitions.</summary>
|
||||
Oval,
|
||||
|
||||
/// <summary>Security advisories from a vendor or project.</summary>
|
||||
Advisory,
|
||||
|
||||
/// <summary>Ecosystem-specific vulnerability database.</summary>
|
||||
EcosystemSpecific
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about a vulnerability feed.
|
||||
/// </summary>
|
||||
/// <param name="FeedId">Feed identifier.</param>
|
||||
/// <param name="LastModified">When the feed was last updated.</param>
|
||||
/// <param name="TotalEntries">Total number of entries in the feed.</param>
|
||||
/// <param name="SchemaVersion">Schema version of the feed format.</param>
|
||||
/// <param name="Description">Description of the feed.</param>
|
||||
public sealed record FeedMetadata(
|
||||
string FeedId,
|
||||
DateTimeOffset? LastModified,
|
||||
long? TotalEntries,
|
||||
string? SchemaVersion,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerability entry from a feed.
|
||||
/// </summary>
|
||||
/// <param name="Id">Primary identifier (CVE ID or feed-specific ID).</param>
|
||||
/// <param name="Aliases">Alternate identifiers for this vulnerability.</param>
|
||||
/// <param name="Summary">Short description of the vulnerability.</param>
|
||||
/// <param name="Description">Full description.</param>
|
||||
/// <param name="Severity">Severity information.</param>
|
||||
/// <param name="AffectedPackages">List of affected packages.</param>
|
||||
/// <param name="References">Reference URLs.</param>
|
||||
/// <param name="PublishedAt">When the vulnerability was published.</param>
|
||||
/// <param name="ModifiedAt">When the entry was last modified.</param>
|
||||
/// <param name="WithdrawnAt">When the entry was withdrawn, if applicable.</param>
|
||||
/// <param name="SourceFeed">The feed this entry came from.</param>
|
||||
/// <param name="RawData">Raw JSON data for debugging.</param>
|
||||
public sealed record FeedEntry(
|
||||
string Id,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string Summary,
|
||||
string? Description,
|
||||
SeverityInfo? Severity,
|
||||
IReadOnlyList<AffectedPackage> AffectedPackages,
|
||||
IReadOnlyList<ReferenceUrl> References,
|
||||
DateTimeOffset? PublishedAt,
|
||||
DateTimeOffset? ModifiedAt,
|
||||
DateTimeOffset? WithdrawnAt,
|
||||
string SourceFeed,
|
||||
string? RawData = null);
|
||||
|
||||
/// <summary>
|
||||
/// Severity information for a vulnerability.
|
||||
/// </summary>
|
||||
/// <param name="Score">CVSS score (0.0-10.0).</param>
|
||||
/// <param name="Vector">CVSS vector string.</param>
|
||||
/// <param name="Version">CVSS version (2.0, 3.0, 3.1).</param>
|
||||
/// <param name="Severity">Textual severity (LOW, MEDIUM, HIGH, CRITICAL).</param>
|
||||
public sealed record SeverityInfo(
|
||||
double? Score,
|
||||
string? Vector,
|
||||
string? Version,
|
||||
string? Severity);
|
||||
|
||||
/// <summary>
|
||||
/// An affected package in a vulnerability entry.
|
||||
/// </summary>
|
||||
/// <param name="Ecosystem">Package ecosystem.</param>
|
||||
/// <param name="Name">Package name.</param>
|
||||
/// <param name="Purl">Package URL.</param>
|
||||
/// <param name="AffectedVersions">Version ranges affected.</param>
|
||||
/// <param name="FixedVersions">Versions where the vulnerability is fixed.</param>
|
||||
public sealed record AffectedPackage(
|
||||
string Ecosystem,
|
||||
string Name,
|
||||
string? Purl,
|
||||
IReadOnlyList<VersionRange> AffectedVersions,
|
||||
IReadOnlyList<string> FixedVersions);
|
||||
|
||||
/// <summary>
|
||||
/// A version range for affected packages.
|
||||
/// </summary>
|
||||
/// <param name="Introduced">Version where vulnerability was introduced.</param>
|
||||
/// <param name="Fixed">Version where vulnerability was fixed.</param>
|
||||
/// <param name="LastAffected">Last affected version.</param>
|
||||
public sealed record VersionRange(
|
||||
string? Introduced,
|
||||
string? Fixed,
|
||||
string? LastAffected);
|
||||
|
||||
/// <summary>
|
||||
/// A reference URL for a vulnerability.
|
||||
/// </summary>
|
||||
/// <param name="Url">The URL.</param>
|
||||
/// <param name="Type">Type of reference.</param>
|
||||
public sealed record ReferenceUrl(
|
||||
string Url,
|
||||
ReferenceType Type);
|
||||
|
||||
/// <summary>
|
||||
/// Types of vulnerability references.
|
||||
/// </summary>
|
||||
public enum ReferenceType
|
||||
{
|
||||
/// <summary>Generic advisory.</summary>
|
||||
Advisory,
|
||||
|
||||
/// <summary>Article or blog post.</summary>
|
||||
Article,
|
||||
|
||||
/// <summary>Detection method.</summary>
|
||||
Detection,
|
||||
|
||||
/// <summary>Discussion thread.</summary>
|
||||
Discussion,
|
||||
|
||||
/// <summary>Report or technical document.</summary>
|
||||
Report,
|
||||
|
||||
/// <summary>Fix commit or patch.</summary>
|
||||
Fix,
|
||||
|
||||
/// <summary>Proof of concept or exploit.</summary>
|
||||
Exploit,
|
||||
|
||||
/// <summary>Related package.</summary>
|
||||
Package,
|
||||
|
||||
/// <summary>Vendor advisory.</summary>
|
||||
Vendor
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for Large Language Model inference.
|
||||
/// Plugins implementing this interface can generate completions, stream responses,
|
||||
/// and provide model information.
|
||||
/// </summary>
|
||||
public interface ILlmCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider identifier (e.g., "openai", "claude", "ollama", "llama-server").
|
||||
/// </summary>
|
||||
string ProviderId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the priority for provider selection (higher = preferred).
|
||||
/// </summary>
|
||||
int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of available models from this provider.
|
||||
/// </summary>
|
||||
IReadOnlyList<LlmModelInfo> AvailableModels { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the provider is available and configured.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if the provider is available.</returns>
|
||||
Task<bool> IsAvailableAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a completion from a prompt.
|
||||
/// </summary>
|
||||
/// <param name="request">Completion request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Completion result.</returns>
|
||||
Task<LlmCompletionResult> CompleteAsync(LlmCompletionRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a completion with streaming output.
|
||||
/// </summary>
|
||||
/// <param name="request">Completion request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of completion chunks.</returns>
|
||||
IAsyncEnumerable<LlmStreamChunk> CompleteStreamAsync(LlmCompletionRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generates embeddings for the given text.
|
||||
/// </summary>
|
||||
/// <param name="text">Text to embed.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Embedding result, or null if not supported.</returns>
|
||||
Task<LlmEmbeddingResult?> EmbedAsync(string text, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available LLM model.
|
||||
/// </summary>
|
||||
/// <param name="Id">Model identifier.</param>
|
||||
/// <param name="Name">Display name.</param>
|
||||
/// <param name="Description">Optional description.</param>
|
||||
/// <param name="ParameterCount">Number of parameters (if known).</param>
|
||||
/// <param name="ContextLength">Maximum context length in tokens.</param>
|
||||
/// <param name="Capabilities">Model capabilities (e.g., "chat", "completion", "embedding").</param>
|
||||
public sealed record LlmModelInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
long? ParameterCount,
|
||||
int? ContextLength,
|
||||
IReadOnlyList<string> Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Request for LLM completion.
|
||||
/// </summary>
|
||||
/// <param name="UserPrompt">User prompt (main input).</param>
|
||||
/// <param name="SystemPrompt">Optional system prompt (instructions).</param>
|
||||
/// <param name="Model">Optional model to use (provider-specific).</param>
|
||||
/// <param name="Temperature">Temperature (0 = deterministic).</param>
|
||||
/// <param name="MaxTokens">Maximum tokens to generate.</param>
|
||||
/// <param name="Seed">Random seed for reproducibility.</param>
|
||||
/// <param name="StopSequences">Stop sequences.</param>
|
||||
/// <param name="RequestId">Request ID for tracing.</param>
|
||||
public sealed record LlmCompletionRequest(
|
||||
string UserPrompt,
|
||||
string? SystemPrompt = null,
|
||||
string? Model = null,
|
||||
double Temperature = 0,
|
||||
int MaxTokens = 4096,
|
||||
int? Seed = null,
|
||||
IReadOnlyList<string>? StopSequences = null,
|
||||
string? RequestId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result from LLM completion.
|
||||
/// </summary>
|
||||
/// <param name="Content">Generated content.</param>
|
||||
/// <param name="ModelId">Model used.</param>
|
||||
/// <param name="ProviderId">Provider ID.</param>
|
||||
/// <param name="InputTokens">Input tokens used.</param>
|
||||
/// <param name="OutputTokens">Output tokens generated.</param>
|
||||
/// <param name="TimeToFirstTokenMs">Time to first token in milliseconds.</param>
|
||||
/// <param name="TotalTimeMs">Total inference time in milliseconds.</param>
|
||||
/// <param name="FinishReason">Finish reason (stop, length, etc.).</param>
|
||||
/// <param name="Deterministic">Whether output is deterministic.</param>
|
||||
/// <param name="RequestId">Request ID for tracing.</param>
|
||||
public sealed record LlmCompletionResult(
|
||||
string Content,
|
||||
string ModelId,
|
||||
string ProviderId,
|
||||
int? InputTokens,
|
||||
int? OutputTokens,
|
||||
long? TimeToFirstTokenMs,
|
||||
long? TotalTimeMs,
|
||||
string? FinishReason,
|
||||
bool Deterministic,
|
||||
string? RequestId);
|
||||
|
||||
/// <summary>
|
||||
/// Streaming chunk from LLM.
|
||||
/// </summary>
|
||||
/// <param name="Content">Content delta.</param>
|
||||
/// <param name="IsFinal">Whether this is the final chunk.</param>
|
||||
/// <param name="FinishReason">Finish reason (only on final chunk).</param>
|
||||
public sealed record LlmStreamChunk(
|
||||
string Content,
|
||||
bool IsFinal,
|
||||
string? FinishReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result from embedding generation.
|
||||
/// </summary>
|
||||
/// <param name="Vector">Embedding vector.</param>
|
||||
/// <param name="Dimensions">Vector dimensions.</param>
|
||||
/// <param name="TokensUsed">Tokens used for embedding.</param>
|
||||
public sealed record LlmEmbeddingResult(
|
||||
float[] Vector,
|
||||
int Dimensions,
|
||||
int TokensUsed);
|
||||
@@ -0,0 +1,194 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for source control management systems.
|
||||
/// </summary>
|
||||
public interface IScmCapability : IConnectorCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// SCM type (github, gitlab, azdo, gitea, bitbucket).
|
||||
/// </summary>
|
||||
string ScmType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this connector can handle the given repository URL.
|
||||
/// Used for auto-detection.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL to check.</param>
|
||||
/// <returns>True if this connector can handle the URL.</returns>
|
||||
bool CanHandle(string repositoryUrl);
|
||||
|
||||
/// <summary>
|
||||
/// List branches in a repository.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of branches.</returns>
|
||||
Task<IReadOnlyList<ScmBranch>> ListBranchesAsync(string repositoryUrl, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// List commits on a branch.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="branch">The branch name.</param>
|
||||
/// <param name="limit">Maximum number of commits to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of commits.</returns>
|
||||
Task<IReadOnlyList<ScmCommit>> ListCommitsAsync(
|
||||
string repositoryUrl,
|
||||
string branch,
|
||||
int limit = 50,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get details of a specific commit.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="commitSha">The commit SHA.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Commit details.</returns>
|
||||
Task<ScmCommit> GetCommitAsync(string repositoryUrl, string commitSha, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get file content at a specific ref.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="filePath">Path to the file in the repository.</param>
|
||||
/// <param name="reference">Git reference (branch, tag, or SHA). Null for default branch.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>File content.</returns>
|
||||
Task<ScmFileContent> GetFileAsync(
|
||||
string repositoryUrl,
|
||||
string filePath,
|
||||
string? reference = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Download repository archive.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="reference">Git reference (branch, tag, or SHA).</param>
|
||||
/// <param name="format">Archive format.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Stream containing the archive.</returns>
|
||||
Task<Stream> GetArchiveAsync(
|
||||
string repositoryUrl,
|
||||
string reference,
|
||||
ArchiveFormat format = ArchiveFormat.TarGz,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Create or update a webhook.
|
||||
/// </summary>
|
||||
/// <param name="repositoryUrl">The repository URL.</param>
|
||||
/// <param name="config">Webhook configuration.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Created or updated webhook.</returns>
|
||||
Task<ScmWebhook> UpsertWebhookAsync(
|
||||
string repositoryUrl,
|
||||
ScmWebhookConfig config,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get current authenticated user info.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>User information.</returns>
|
||||
Task<ScmUser> GetCurrentUserAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a branch in a repository.
|
||||
/// </summary>
|
||||
/// <param name="Name">Branch name.</param>
|
||||
/// <param name="CommitSha">SHA of the commit at the branch head.</param>
|
||||
/// <param name="IsDefault">Whether this is the default branch.</param>
|
||||
/// <param name="IsProtected">Whether the branch is protected.</param>
|
||||
public sealed record ScmBranch(
|
||||
string Name,
|
||||
string CommitSha,
|
||||
bool IsDefault,
|
||||
bool IsProtected);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a commit in a repository.
|
||||
/// </summary>
|
||||
/// <param name="Sha">Full commit SHA.</param>
|
||||
/// <param name="Message">Commit message.</param>
|
||||
/// <param name="AuthorName">Author name.</param>
|
||||
/// <param name="AuthorEmail">Author email.</param>
|
||||
/// <param name="AuthoredAt">When the commit was authored.</param>
|
||||
/// <param name="ParentShas">SHAs of parent commits.</param>
|
||||
public sealed record ScmCommit(
|
||||
string Sha,
|
||||
string Message,
|
||||
string AuthorName,
|
||||
string AuthorEmail,
|
||||
DateTimeOffset AuthoredAt,
|
||||
IReadOnlyList<string> ParentShas);
|
||||
|
||||
/// <summary>
|
||||
/// Represents file content from a repository.
|
||||
/// </summary>
|
||||
/// <param name="Path">File path in the repository.</param>
|
||||
/// <param name="Content">File content (may be base64 encoded).</param>
|
||||
/// <param name="Encoding">Content encoding (e.g., "base64", "utf-8").</param>
|
||||
/// <param name="Sha">Blob SHA.</param>
|
||||
/// <param name="Size">File size in bytes.</param>
|
||||
public sealed record ScmFileContent(
|
||||
string Path,
|
||||
string Content,
|
||||
string Encoding,
|
||||
string Sha,
|
||||
long Size);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a webhook in a repository.
|
||||
/// </summary>
|
||||
/// <param name="Id">Webhook identifier.</param>
|
||||
/// <param name="Url">Webhook callback URL.</param>
|
||||
/// <param name="Events">Events the webhook is subscribed to.</param>
|
||||
/// <param name="Active">Whether the webhook is active.</param>
|
||||
public sealed record ScmWebhook(
|
||||
string Id,
|
||||
string Url,
|
||||
IReadOnlyList<string> Events,
|
||||
bool Active);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for creating or updating a webhook.
|
||||
/// </summary>
|
||||
/// <param name="Url">Callback URL for the webhook.</param>
|
||||
/// <param name="Secret">Secret for webhook signature verification.</param>
|
||||
/// <param name="Events">Events to subscribe to.</param>
|
||||
public sealed record ScmWebhookConfig(
|
||||
string Url,
|
||||
string Secret,
|
||||
IReadOnlyList<string> Events);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an SCM user.
|
||||
/// </summary>
|
||||
/// <param name="Id">User identifier.</param>
|
||||
/// <param name="Username">Username/login.</param>
|
||||
/// <param name="DisplayName">Display name.</param>
|
||||
/// <param name="Email">Email address.</param>
|
||||
/// <param name="AvatarUrl">URL to user's avatar.</param>
|
||||
public sealed record ScmUser(
|
||||
string Id,
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
string? Email,
|
||||
string? AvatarUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Archive formats for repository downloads.
|
||||
/// </summary>
|
||||
public enum ArchiveFormat
|
||||
{
|
||||
/// <summary>Gzipped tar archive.</summary>
|
||||
TarGz,
|
||||
|
||||
/// <summary>ZIP archive.</summary>
|
||||
Zip
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Capability interface for message transport plugins.
|
||||
/// Plugins implementing this interface can provide client/server
|
||||
/// transport functionality for inter-service communication.
|
||||
/// </summary>
|
||||
public interface ITransportCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the transport identifier (e.g., "tcp", "tls", "udp", "rabbitmq", "inmemory").
|
||||
/// </summary>
|
||||
string TransportId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the transport.
|
||||
/// </summary>
|
||||
string TransportDisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport protocol type.
|
||||
/// </summary>
|
||||
TransportProtocol Protocol { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport features supported by this implementation.
|
||||
/// </summary>
|
||||
TransportFeatures Features { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport server that can accept incoming connections.
|
||||
/// </summary>
|
||||
/// <param name="options">Server configuration options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Transport server instance.</returns>
|
||||
Task<ITransportServerInstance> CreateServerAsync(
|
||||
TransportServerOptions options,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport client for sending requests.
|
||||
/// </summary>
|
||||
/// <param name="options">Client configuration options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Transport client instance.</returns>
|
||||
Task<ITransportClientInstance> CreateClientAsync(
|
||||
TransportClientOptions options,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity to a remote endpoint.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Endpoint to test.</param>
|
||||
/// <param name="timeout">Timeout for the test.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Connection test result.</returns>
|
||||
Task<TransportConnectionTestResult> TestConnectionAsync(
|
||||
TransportEndpoint endpoint,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport protocol types.
|
||||
/// </summary>
|
||||
public enum TransportProtocol
|
||||
{
|
||||
/// <summary>Raw TCP protocol.</summary>
|
||||
Tcp,
|
||||
|
||||
/// <summary>TCP with TLS encryption.</summary>
|
||||
Tls,
|
||||
|
||||
/// <summary>UDP protocol.</summary>
|
||||
Udp,
|
||||
|
||||
/// <summary>AMQP protocol (RabbitMQ).</summary>
|
||||
Amqp,
|
||||
|
||||
/// <summary>Redis protocol (Valkey).</summary>
|
||||
Redis,
|
||||
|
||||
/// <summary>In-memory transport (testing).</summary>
|
||||
InMemory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feature flags for transport capabilities.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum TransportFeatures
|
||||
{
|
||||
/// <summary>No special features.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Transport supports request/reply pattern.</summary>
|
||||
RequestReply = 1 << 0,
|
||||
|
||||
/// <summary>Transport supports streaming.</summary>
|
||||
Streaming = 1 << 1,
|
||||
|
||||
/// <summary>Transport supports publish/subscribe.</summary>
|
||||
PubSub = 1 << 2,
|
||||
|
||||
/// <summary>Transport supports message queuing.</summary>
|
||||
Queuing = 1 << 3,
|
||||
|
||||
/// <summary>Transport supports TLS encryption.</summary>
|
||||
TlsSupport = 1 << 4,
|
||||
|
||||
/// <summary>Transport supports connection pooling.</summary>
|
||||
ConnectionPooling = 1 << 5,
|
||||
|
||||
/// <summary>Transport supports automatic reconnection.</summary>
|
||||
AutoReconnect = 1 << 6,
|
||||
|
||||
/// <summary>Transport supports message acknowledgment.</summary>
|
||||
Acknowledgment = 1 << 7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a transport endpoint.
|
||||
/// </summary>
|
||||
/// <param name="Host">Host name or IP address.</param>
|
||||
/// <param name="Port">Port number.</param>
|
||||
/// <param name="Path">Optional path (for virtual hosts, queues, etc.).</param>
|
||||
/// <param name="Parameters">Additional connection parameters.</param>
|
||||
public sealed record TransportEndpoint(
|
||||
string Host,
|
||||
int Port,
|
||||
string? Path = null,
|
||||
IReadOnlyDictionary<string, string>? Parameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for creating a transport server.
|
||||
/// </summary>
|
||||
/// <param name="Endpoint">Endpoint to bind to.</param>
|
||||
/// <param name="Backlog">Connection backlog size.</param>
|
||||
/// <param name="MaxConnections">Maximum concurrent connections.</param>
|
||||
/// <param name="IdleTimeout">Idle connection timeout.</param>
|
||||
/// <param name="TlsCertificatePath">Path to TLS certificate (if applicable).</param>
|
||||
/// <param name="TlsCertificatePassword">TLS certificate password.</param>
|
||||
public sealed record TransportServerOptions(
|
||||
TransportEndpoint Endpoint,
|
||||
int Backlog = 100,
|
||||
int MaxConnections = 1000,
|
||||
TimeSpan? IdleTimeout = null,
|
||||
string? TlsCertificatePath = null,
|
||||
string? TlsCertificatePassword = null);
|
||||
|
||||
/// <summary>
|
||||
/// Options for creating a transport client.
|
||||
/// </summary>
|
||||
/// <param name="Endpoint">Remote endpoint to connect to.</param>
|
||||
/// <param name="ConnectTimeout">Connection timeout.</param>
|
||||
/// <param name="ReadTimeout">Read operation timeout.</param>
|
||||
/// <param name="WriteTimeout">Write operation timeout.</param>
|
||||
/// <param name="BufferSize">Buffer size in bytes.</param>
|
||||
/// <param name="KeepAlive">Enable keep-alive.</param>
|
||||
/// <param name="ValidateServerCertificate">Validate server TLS certificate.</param>
|
||||
public sealed record TransportClientOptions(
|
||||
TransportEndpoint Endpoint,
|
||||
TimeSpan? ConnectTimeout = null,
|
||||
TimeSpan? ReadTimeout = null,
|
||||
TimeSpan? WriteTimeout = null,
|
||||
int BufferSize = 65536,
|
||||
bool KeepAlive = true,
|
||||
bool ValidateServerCertificate = true);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a connection test.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the connection was successful.</param>
|
||||
/// <param name="Latency">Round-trip latency.</param>
|
||||
/// <param name="Message">Status or error message.</param>
|
||||
/// <param name="ServerInfo">Optional server information.</param>
|
||||
public sealed record TransportConnectionTestResult(
|
||||
bool Success,
|
||||
TimeSpan? Latency,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string>? ServerInfo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an active transport server instance.
|
||||
/// </summary>
|
||||
public interface ITransportServerInstance : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the server is running.
|
||||
/// </summary>
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local endpoint the server is bound to.
|
||||
/// </summary>
|
||||
TransportEndpoint LocalEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current number of active connections.
|
||||
/// </summary>
|
||||
int ActiveConnections { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the server.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the server gracefully.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an active transport client instance.
|
||||
/// </summary>
|
||||
public interface ITransportClientInstance : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the client is connected.
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remote endpoint.
|
||||
/// </summary>
|
||||
TransportEndpoint RemoteEndpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request and waits for a response.
|
||||
/// </summary>
|
||||
/// <param name="request">Request message.</param>
|
||||
/// <param name="timeout">Request timeout.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Response message.</returns>
|
||||
Task<TransportMessage> SendRequestAsync(
|
||||
TransportMessage request,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a streaming request.
|
||||
/// </summary>
|
||||
/// <param name="header">Request header.</param>
|
||||
/// <param name="bodyStream">Request body stream.</param>
|
||||
/// <param name="responseHandler">Handler for response stream.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendStreamingAsync(
|
||||
TransportMessage header,
|
||||
Stream bodyStream,
|
||||
Func<Stream, Task> responseHandler,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a cancellation request.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID of request to cancel.</param>
|
||||
/// <param name="reason">Optional cancellation reason.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendCancelAsync(
|
||||
string correlationId,
|
||||
string? reason,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport message envelope.
|
||||
/// </summary>
|
||||
/// <param name="Id">Message identifier.</param>
|
||||
/// <param name="Payload">Message payload.</param>
|
||||
/// <param name="ContentType">Content type (e.g., "application/json").</param>
|
||||
/// <param name="CorrelationId">Correlation ID for request/reply.</param>
|
||||
/// <param name="ReplyTo">Reply-to address.</param>
|
||||
/// <param name="Headers">Additional headers.</param>
|
||||
/// <param name="Timestamp">Message timestamp.</param>
|
||||
/// <param name="Ttl">Time-to-live.</param>
|
||||
public sealed record TransportMessage(
|
||||
string Id,
|
||||
ReadOnlyMemory<byte> Payload,
|
||||
string? ContentType = null,
|
||||
string? CorrelationId = null,
|
||||
string? ReplyTo = null,
|
||||
IReadOnlyDictionary<string, string>? Headers = null,
|
||||
DateTimeOffset? Timestamp = null,
|
||||
TimeSpan? Ttl = null);
|
||||
@@ -0,0 +1,120 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to platform services and configuration during plugin execution.
|
||||
/// Passed to <see cref="IPlugin.InitializeAsync"/> and available throughout plugin lifetime.
|
||||
/// </summary>
|
||||
public interface IPluginContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin-specific configuration bound from manifest and runtime settings.
|
||||
/// </summary>
|
||||
IPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Scoped logger for plugin diagnostics. Automatically tagged with plugin ID.
|
||||
/// </summary>
|
||||
IPluginLogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Service locator for accessing platform services.
|
||||
/// Available services depend on plugin trust level and declared capabilities.
|
||||
/// </summary>
|
||||
IPluginServices Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current tenant ID, if plugin is running in tenant context.
|
||||
/// Null for global plugins.
|
||||
/// </summary>
|
||||
Guid? TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique instance ID for this plugin activation.
|
||||
/// </summary>
|
||||
Guid InstanceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cancellation token that fires when shutdown is requested.
|
||||
/// Plugins should monitor this and clean up gracefully.
|
||||
/// </summary>
|
||||
CancellationToken ShutdownToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time provider for deterministic time operations.
|
||||
/// </summary>
|
||||
TimeProvider TimeProvider { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-specific configuration access.
|
||||
/// </summary>
|
||||
public interface IPluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a configuration value by key.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Target type for conversion.</typeparam>
|
||||
/// <param name="key">Configuration key (dot-separated path).</param>
|
||||
/// <param name="defaultValue">Default if key not found.</param>
|
||||
/// <returns>The configuration value or default.</returns>
|
||||
T? GetValue<T>(string key, T? defaultValue = default);
|
||||
|
||||
/// <summary>
|
||||
/// Binds configuration section to a strongly-typed options class.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Options type with properties matching config keys.</typeparam>
|
||||
/// <param name="sectionKey">Section key, or null for root.</param>
|
||||
/// <returns>Bound options instance.</returns>
|
||||
T Bind<T>(string? sectionKey = null) where T : class, new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a secret value from the configured vault.
|
||||
/// </summary>
|
||||
/// <param name="secretName">Name of the secret.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Secret value, or null if not found.</returns>
|
||||
Task<string?> GetSecretAsync(string secretName, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a configuration key exists.
|
||||
/// </summary>
|
||||
/// <param name="key">Configuration key to check.</param>
|
||||
/// <returns>True if the key exists.</returns>
|
||||
bool HasKey(string key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service locator for accessing platform services from plugins.
|
||||
/// </summary>
|
||||
public interface IPluginServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a required service. Throws if not available.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Service type.</typeparam>
|
||||
/// <exception cref="InvalidOperationException">If service not available.</exception>
|
||||
/// <returns>The requested service.</returns>
|
||||
T GetRequiredService<T>() where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional service. Returns null if not available.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Service type.</typeparam>
|
||||
/// <returns>The service or null.</returns>
|
||||
T? GetService<T>() where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered implementations of a service.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Service type.</typeparam>
|
||||
/// <returns>Enumerable of service implementations.</returns>
|
||||
IEnumerable<T> GetServices<T>() where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scoped service provider for the current operation.
|
||||
/// Dispose the scope when operation completes.
|
||||
/// </summary>
|
||||
/// <param name="scopedServices">Output parameter for scoped services.</param>
|
||||
/// <returns>Disposable scope.</returns>
|
||||
IAsyncDisposable CreateScope(out IPluginServices scopedServices);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin logging interface with structured logging support.
|
||||
/// </summary>
|
||||
public interface IPluginLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs a message at the specified level.
|
||||
/// </summary>
|
||||
/// <param name="level">Log level.</param>
|
||||
/// <param name="message">Message template with placeholders.</param>
|
||||
/// <param name="args">Values to substitute into placeholders.</param>
|
||||
void Log(LogLevel level, string message, params object[] args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs a message with an exception at the specified level.
|
||||
/// </summary>
|
||||
/// <param name="level">Log level.</param>
|
||||
/// <param name="exception">The exception to log.</param>
|
||||
/// <param name="message">Message template with placeholders.</param>
|
||||
/// <param name="args">Values to substitute into placeholders.</param>
|
||||
void Log(LogLevel level, Exception exception, string message, params object[] args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs a debug message.
|
||||
/// </summary>
|
||||
/// <param name="message">Message template.</param>
|
||||
/// <param name="args">Values to substitute.</param>
|
||||
void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs an informational message.
|
||||
/// </summary>
|
||||
/// <param name="message">Message template.</param>
|
||||
/// <param name="args">Values to substitute.</param>
|
||||
void Info(string message, params object[] args) => Log(LogLevel.Information, message, args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs a warning message.
|
||||
/// </summary>
|
||||
/// <param name="message">Message template.</param>
|
||||
/// <param name="args">Values to substitute.</param>
|
||||
void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs an error message.
|
||||
/// </summary>
|
||||
/// <param name="message">Message template.</param>
|
||||
/// <param name="args">Values to substitute.</param>
|
||||
void Error(string message, params object[] args) => Log(LogLevel.Error, message, args);
|
||||
|
||||
/// <summary>
|
||||
/// Logs an error message with exception.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <param name="message">Message template.</param>
|
||||
/// <param name="args">Values to substitute.</param>
|
||||
void Error(Exception ex, string message, params object[] args) => Log(LogLevel.Error, ex, message, args);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scoped logger with additional properties.
|
||||
/// </summary>
|
||||
/// <param name="name">Property name.</param>
|
||||
/// <param name="value">Property value.</param>
|
||||
/// <returns>A new logger with the additional property.</returns>
|
||||
IPluginLogger WithProperty(string name, object value);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scoped logger for a specific operation.
|
||||
/// </summary>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <returns>A new logger scoped to the operation.</returns>
|
||||
IPluginLogger ForOperation(string operationName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the specified log level is enabled.
|
||||
/// </summary>
|
||||
/// <param name="level">The log level to check.</param>
|
||||
/// <returns>True if the level is enabled.</returns>
|
||||
bool IsEnabled(LogLevel level);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for plugin loaders that discover and load plugins.
|
||||
/// </summary>
|
||||
public interface IPluginLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Discover plugins in a directory.
|
||||
/// </summary>
|
||||
/// <param name="directory">Directory to search.</param>
|
||||
/// <param name="searchSubdirectories">Whether to search subdirectories.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of discovered plugin manifests.</returns>
|
||||
Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
string directory,
|
||||
bool searchSubdirectories = true,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Load a plugin from a manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result.</returns>
|
||||
Task<PluginLoadResult> LoadAsync(PluginManifest manifest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Load a plugin directly from a type.
|
||||
/// Used for built-in plugins.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The plugin type.</typeparam>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result.</returns>
|
||||
Task<PluginLoadResult> LoadAsync<T>(CancellationToken ct) where T : class, IPlugin, new();
|
||||
|
||||
/// <summary>
|
||||
/// Unload a plugin and release its resources.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The plugin to unload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnloadAsync(LoadedPlugin plugin, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for parsing plugin manifests from different formats.
|
||||
/// </summary>
|
||||
public interface IManifestParser
|
||||
{
|
||||
/// <summary>
|
||||
/// File extensions this parser can handle.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedExtensions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parse a manifest from a file.
|
||||
/// </summary>
|
||||
/// <param name="filePath">Path to the manifest file.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The parsed manifest.</returns>
|
||||
Task<PluginManifest> ParseAsync(string filePath, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a manifest from a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The manifest stream.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The parsed manifest.</returns>
|
||||
Task<PluginManifest> ParseAsync(Stream stream, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fully loaded and initialized plugin.
|
||||
/// Created by the plugin host after successful loading and initialization.
|
||||
/// </summary>
|
||||
public sealed class LoadedPlugin : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The underlying plugin instance.
|
||||
/// </summary>
|
||||
public IPlugin Instance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin metadata.
|
||||
/// </summary>
|
||||
public PluginInfo Info => Instance.Info;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin manifest, if loaded from file.
|
||||
/// </summary>
|
||||
public PluginManifest? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level of the plugin.
|
||||
/// </summary>
|
||||
public PluginTrustLevel TrustLevel => Instance.TrustLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities provided by the plugin.
|
||||
/// </summary>
|
||||
public PluginCapabilities Capabilities => Instance.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Current lifecycle state.
|
||||
/// </summary>
|
||||
public PluginLifecycleState State => Instance.State;
|
||||
|
||||
/// <summary>
|
||||
/// When the plugin was loaded.
|
||||
/// </summary>
|
||||
public DateTimeOffset LoadedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the plugin was last health checked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastHealthCheck { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of the last health check.
|
||||
/// </summary>
|
||||
public HealthCheckResult? LastHealthCheckResult { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new loaded plugin wrapper.
|
||||
/// </summary>
|
||||
/// <param name="instance">The plugin instance.</param>
|
||||
public LoadedPlugin(IPlugin instance)
|
||||
{
|
||||
Instance = instance ?? throw new ArgumentNullException(nameof(instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a capability interface if the plugin provides it.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <returns>The capability, or null if not provided.</returns>
|
||||
public T? GetCapability<T>() where T : class
|
||||
{
|
||||
return Instance as T;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin provides the specified capability interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <returns>True if the plugin implements the capability.</returns>
|
||||
public bool HasCapability<T>() where T : class
|
||||
{
|
||||
return Instance is T;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a health check and updates the cached result.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The health check result.</returns>
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
var result = await Instance.HealthCheckAsync(ct);
|
||||
LastHealthCheck = DateTimeOffset.UtcNow;
|
||||
LastHealthCheckResult = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the plugin.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Instance.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin load operation.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether loading succeeded.</param>
|
||||
/// <param name="Plugin">The loaded plugin, if successful.</param>
|
||||
/// <param name="Error">Error message, if failed.</param>
|
||||
/// <param name="Exception">Exception, if failed.</param>
|
||||
public sealed record PluginLoadResult(
|
||||
bool Success,
|
||||
LoadedPlugin? Plugin,
|
||||
string? Error,
|
||||
Exception? Exception = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful load result.
|
||||
/// </summary>
|
||||
public static PluginLoadResult Succeeded(LoadedPlugin plugin) =>
|
||||
new(true, plugin, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed load result.
|
||||
/// </summary>
|
||||
public static PluginLoadResult Failed(string error, Exception? exception = null) =>
|
||||
new(false, null, error, exception);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin health check.
|
||||
/// </summary>
|
||||
/// <param name="Status">Overall health status.</param>
|
||||
/// <param name="Message">Optional message describing status.</param>
|
||||
/// <param name="Duration">Time taken to perform health check.</param>
|
||||
/// <param name="Details">Additional diagnostic details.</param>
|
||||
public sealed record HealthCheckResult(
|
||||
HealthStatus Status,
|
||||
string? Message = null,
|
||||
TimeSpan? Duration = null,
|
||||
IReadOnlyDictionary<string, object>? Details = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a healthy result.
|
||||
/// </summary>
|
||||
/// <param name="message">Optional message.</param>
|
||||
/// <returns>A healthy result.</returns>
|
||||
public static HealthCheckResult Healthy(string? message = null) =>
|
||||
new(HealthStatus.Healthy, message);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a healthy result with duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">Time taken for the health check.</param>
|
||||
/// <param name="message">Optional message.</param>
|
||||
/// <returns>A healthy result with duration.</returns>
|
||||
public static HealthCheckResult Healthy(TimeSpan duration, string? message = null) =>
|
||||
new(HealthStatus.Healthy, message, duration);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a degraded result (functioning but impaired).
|
||||
/// </summary>
|
||||
/// <param name="message">Description of the degradation.</param>
|
||||
/// <param name="details">Optional diagnostic details.</param>
|
||||
/// <returns>A degraded result.</returns>
|
||||
public static HealthCheckResult Degraded(string message, IReadOnlyDictionary<string, object>? details = null) =>
|
||||
new(HealthStatus.Degraded, message, Details: details);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unhealthy result.
|
||||
/// </summary>
|
||||
/// <param name="message">Description of the failure.</param>
|
||||
/// <param name="details">Optional diagnostic details.</param>
|
||||
/// <returns>An unhealthy result.</returns>
|
||||
public static HealthCheckResult Unhealthy(string message, IReadOnlyDictionary<string, object>? details = null) =>
|
||||
new(HealthStatus.Unhealthy, message, Details: details);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unhealthy result from an exception.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that caused the unhealthy state.</param>
|
||||
/// <returns>An unhealthy result with exception details.</returns>
|
||||
public static HealthCheckResult Unhealthy(Exception exception) =>
|
||||
new(HealthStatus.Unhealthy, exception.Message, Details: new Dictionary<string, object>
|
||||
{
|
||||
["exceptionType"] = exception.GetType().Name,
|
||||
["stackTrace"] = exception.StackTrace ?? string.Empty
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unknown result (health check not yet performed).
|
||||
/// </summary>
|
||||
/// <returns>An unknown result.</returns>
|
||||
public static HealthCheckResult Unknown() =>
|
||||
new(HealthStatus.Unknown);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this result with the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration to set.</param>
|
||||
/// <returns>A new result with the duration set.</returns>
|
||||
public HealthCheckResult WithDuration(TimeSpan duration) =>
|
||||
this with { Duration = duration };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this result with additional details merged in.
|
||||
/// </summary>
|
||||
/// <param name="additionalDetails">Additional details to merge.</param>
|
||||
/// <returns>A new result with merged details.</returns>
|
||||
public HealthCheckResult WithDetails(IReadOnlyDictionary<string, object> additionalDetails)
|
||||
{
|
||||
var merged = new Dictionary<string, object>(Details ?? new Dictionary<string, object>());
|
||||
foreach (var kvp in additionalDetails)
|
||||
{
|
||||
merged[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return this with { Details = merged };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health status values for plugins.
|
||||
/// </summary>
|
||||
public enum HealthStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Health status is not known (plugin not yet checked).
|
||||
/// </summary>
|
||||
Unknown = -1,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is healthy and fully operational.
|
||||
/// </summary>
|
||||
Healthy = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is functioning but with degraded performance or partial failures.
|
||||
/// </summary>
|
||||
Degraded = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is not functioning and cannot service requests.
|
||||
/// </summary>
|
||||
Unhealthy = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="HealthStatus"/>.
|
||||
/// </summary>
|
||||
public static class HealthStatusExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the status indicates the plugin can handle requests.
|
||||
/// </summary>
|
||||
public static bool IsOperational(this HealthStatus status) =>
|
||||
status is HealthStatus.Healthy or HealthStatus.Degraded;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the storage string representation.
|
||||
/// </summary>
|
||||
public static string ToStorageString(this HealthStatus status) =>
|
||||
status.ToString().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a storage string back to enum value.
|
||||
/// </summary>
|
||||
public static HealthStatus ParseFromStorage(string value) =>
|
||||
Enum.Parse<HealthStatus>(value, ignoreCase: true);
|
||||
}
|
||||
71
src/Plugin/StellaOps.Plugin.Abstractions/IPlugin.cs
Normal file
71
src/Plugin/StellaOps.Plugin.Abstractions/IPlugin.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Core interface that all Stella Ops plugins must implement.
|
||||
/// Plugins provide one or more capabilities to the platform.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The plugin lifecycle follows these phases:
|
||||
/// <list type="number">
|
||||
/// <item>Discovery - Plugin assembly found and manifest parsed</item>
|
||||
/// <item>Loading - Assembly loaded, types resolved</item>
|
||||
/// <item>Initialization - <see cref="InitializeAsync"/> called with context</item>
|
||||
/// <item>Active - Plugin servicing requests</item>
|
||||
/// <item>Shutdown - <see cref="IAsyncDisposable.DisposeAsync"/> called for cleanup</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Plugins declare their trust level, which determines execution context:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="PluginTrustLevel.BuiltIn"/> - Runs in-process, full access</item>
|
||||
/// <item><see cref="PluginTrustLevel.Trusted"/> - Runs isolated, monitored</item>
|
||||
/// <item><see cref="PluginTrustLevel.Untrusted"/> - Runs sandboxed, restricted</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IPlugin : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique plugin metadata including ID, version, and vendor.
|
||||
/// </summary>
|
||||
PluginInfo Info { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level determines the execution environment.
|
||||
/// Bundled plugins return <see cref="PluginTrustLevel.BuiltIn"/>.
|
||||
/// Third-party plugins typically return <see cref="PluginTrustLevel.Untrusted"/>.
|
||||
/// </summary>
|
||||
PluginTrustLevel TrustLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities this plugin provides. Used for discovery and routing.
|
||||
/// </summary>
|
||||
PluginCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lifecycle state of the plugin.
|
||||
/// </summary>
|
||||
PluginLifecycleState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the plugin with the provided context.
|
||||
/// Called once after loading, before any capability methods.
|
||||
/// </summary>
|
||||
/// <param name="context">Provides configuration, logging, and service access.</param>
|
||||
/// <param name="ct">Cancellation token for initialization timeout.</param>
|
||||
/// <exception cref="PluginInitializationException">If initialization fails.</exception>
|
||||
Task InitializeAsync(IPluginContext context, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a health check to verify plugin is functioning correctly.
|
||||
/// Called periodically by the plugin host.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token for health check timeout.</param>
|
||||
/// <returns>Health check result with status and optional diagnostics.</returns>
|
||||
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Base exception for plugin lifecycle errors.
|
||||
/// </summary>
|
||||
public class PluginLifecycleException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin ID associated with this exception.
|
||||
/// </summary>
|
||||
public string? PluginId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The lifecycle state when the exception occurred.
|
||||
/// </summary>
|
||||
public PluginLifecycleState? State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle exception.
|
||||
/// </summary>
|
||||
public PluginLifecycleException(string message) : base(message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle exception with an inner exception.
|
||||
/// </summary>
|
||||
public PluginLifecycleException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle exception with plugin context.
|
||||
/// </summary>
|
||||
public PluginLifecycleException(string pluginId, PluginLifecycleState state, string message)
|
||||
: base(message)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
State = state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle exception with plugin context and inner exception.
|
||||
/// </summary>
|
||||
public PluginLifecycleException(string pluginId, PluginLifecycleState state, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
State = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when plugin initialization fails.
|
||||
/// </summary>
|
||||
public class PluginInitializationException : PluginLifecycleException
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new initialization exception.
|
||||
/// </summary>
|
||||
public PluginInitializationException(string message)
|
||||
: base(message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new initialization exception with inner exception.
|
||||
/// </summary>
|
||||
public PluginInitializationException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new initialization exception with plugin context.
|
||||
/// </summary>
|
||||
public PluginInitializationException(string pluginId, string message)
|
||||
: base(pluginId, PluginLifecycleState.Initializing, message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new initialization exception with plugin context and inner exception.
|
||||
/// </summary>
|
||||
public PluginInitializationException(string pluginId, string message, Exception innerException)
|
||||
: base(pluginId, PluginLifecycleState.Initializing, message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when plugin loading fails.
|
||||
/// </summary>
|
||||
public class PluginLoadException : PluginLifecycleException
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new load exception.
|
||||
/// </summary>
|
||||
public PluginLoadException(string message)
|
||||
: base(message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new load exception with inner exception.
|
||||
/// </summary>
|
||||
public PluginLoadException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new load exception with plugin context.
|
||||
/// </summary>
|
||||
public PluginLoadException(string pluginId, string message)
|
||||
: base(pluginId, PluginLifecycleState.Loading, message) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new load exception with plugin context and inner exception.
|
||||
/// </summary>
|
||||
public PluginLoadException(string pluginId, string message, Exception innerException)
|
||||
: base(pluginId, PluginLifecycleState.Loading, message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when an invalid state transition is attempted.
|
||||
/// </summary>
|
||||
public class InvalidStateTransitionException : PluginLifecycleException
|
||||
{
|
||||
/// <summary>
|
||||
/// The state being transitioned from.
|
||||
/// </summary>
|
||||
public PluginLifecycleState FromState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The state being transitioned to.
|
||||
/// </summary>
|
||||
public PluginLifecycleState ToState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new invalid state transition exception.
|
||||
/// </summary>
|
||||
public InvalidStateTransitionException(string pluginId, PluginLifecycleState fromState, PluginLifecycleState toState)
|
||||
: base(pluginId, fromState, $"Invalid state transition from {fromState} to {toState}")
|
||||
{
|
||||
FromState = fromState;
|
||||
ToState = toState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of a plugin in its lifecycle.
|
||||
/// </summary>
|
||||
public enum PluginLifecycleState
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin has been discovered but not yet loaded.
|
||||
/// This is the initial state after manifest parsing.
|
||||
/// </summary>
|
||||
Discovered = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin assembly is being loaded into the application domain.
|
||||
/// </summary>
|
||||
Loading = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is being initialized via <see cref="IPlugin.InitializeAsync"/>.
|
||||
/// </summary>
|
||||
Initializing = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is fully initialized and ready to handle requests.
|
||||
/// This is the normal operating state.
|
||||
/// </summary>
|
||||
Active = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is functioning but with reduced capability or performance.
|
||||
/// May transition back to Active or to Unhealthy.
|
||||
/// </summary>
|
||||
Degraded = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is being stopped gracefully.
|
||||
/// </summary>
|
||||
Stopping = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin has been stopped and is no longer servicing requests.
|
||||
/// </summary>
|
||||
Stopped = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin initialization or operation failed.
|
||||
/// Check status message for details.
|
||||
/// </summary>
|
||||
Failed = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is being unloaded from the application domain.
|
||||
/// </summary>
|
||||
Unloading = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="PluginLifecycleState"/>.
|
||||
/// </summary>
|
||||
public static class PluginLifecycleStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the plugin is in an operational state that can handle requests.
|
||||
/// </summary>
|
||||
public static bool IsOperational(this PluginLifecycleState state) =>
|
||||
state is PluginLifecycleState.Active or PluginLifecycleState.Degraded;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin is in a terminal state (stopped, failed, unloading).
|
||||
/// </summary>
|
||||
public static bool IsTerminal(this PluginLifecycleState state) =>
|
||||
state is PluginLifecycleState.Stopped or PluginLifecycleState.Failed or PluginLifecycleState.Unloading;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin is in a transitional state (loading, initializing, stopping).
|
||||
/// </summary>
|
||||
public static bool IsTransitioning(this PluginLifecycleState state) =>
|
||||
state is PluginLifecycleState.Loading or PluginLifecycleState.Initializing or
|
||||
PluginLifecycleState.Stopping or PluginLifecycleState.Unloading;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the storage string representation.
|
||||
/// </summary>
|
||||
public static string ToStorageString(this PluginLifecycleState state) =>
|
||||
state.ToString().ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Parses a storage string back to enum value.
|
||||
/// </summary>
|
||||
public static PluginLifecycleState ParseFromStorage(string value) =>
|
||||
Enum.Parse<PluginLifecycleState>(value, ignoreCase: true);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin manifest describing plugin metadata and capabilities.
|
||||
/// Typically loaded from plugin.yaml or plugin.json.
|
||||
/// </summary>
|
||||
public sealed class PluginManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("info")]
|
||||
public required PluginInfo Info { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point type name (fully qualified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public required string EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the plugin assembly (relative to manifest).
|
||||
/// </summary>
|
||||
[JsonPropertyName("assemblyPath")]
|
||||
public string? AssemblyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum platform version required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minPlatformVersion")]
|
||||
public string? MinPlatformVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum platform version supported.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxPlatformVersion")]
|
||||
public string? MaxPlatformVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities provided by this plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("capabilities")]
|
||||
public IReadOnlyList<CapabilityDeclaration> Capabilities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Configuration schema for the plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("configSchema")]
|
||||
public JsonDocument? ConfigSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default configuration values.
|
||||
/// </summary>
|
||||
[JsonPropertyName("defaultConfig")]
|
||||
public JsonDocument? DefaultConfig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dependencies on other plugins.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<PluginDependency> Dependencies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Resource requirements and limits.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resources")]
|
||||
public ResourceRequirements? Resources { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Permissions required by the plugin.
|
||||
/// </summary>
|
||||
[JsonPropertyName("permissions")]
|
||||
public IReadOnlyList<string> Permissions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Custom metadata.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the manifest file.
|
||||
/// Set by the loader after reading.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ManifestPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the manifest and throws if invalid.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
Info.Validate();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EntryPoint))
|
||||
throw new ArgumentException("Entry point is required", nameof(EntryPoint));
|
||||
|
||||
foreach (var cap in Capabilities)
|
||||
{
|
||||
cap.Validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declaration of a capability provided by the plugin.
|
||||
/// </summary>
|
||||
public sealed class CapabilityDeclaration
|
||||
{
|
||||
/// <summary>
|
||||
/// Capability type (e.g., "crypto", "scm", "analysis").
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability identifier within the type (e.g., "gost" for crypto, "github" for scm).
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the capability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this capability provides.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration schema for this capability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("configSchema")]
|
||||
public JsonDocument? ConfigSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the capability.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the capability declaration.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Type))
|
||||
throw new ArgumentException("Capability type is required", nameof(Type));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dependency on another plugin.
|
||||
/// </summary>
|
||||
/// <param name="PluginId">ID of the required plugin.</param>
|
||||
/// <param name="VersionConstraint">SemVer version constraint (e.g., ">=1.0.0").</param>
|
||||
/// <param name="Optional">Whether the dependency is optional.</param>
|
||||
public sealed record PluginDependency(
|
||||
[property: JsonPropertyName("pluginId")] string PluginId,
|
||||
[property: JsonPropertyName("version")] string? VersionConstraint = null,
|
||||
[property: JsonPropertyName("optional")] bool Optional = false);
|
||||
|
||||
/// <summary>
|
||||
/// Resource requirements for the plugin.
|
||||
/// </summary>
|
||||
public sealed class ResourceRequirements
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum memory in MB.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minMemoryMb")]
|
||||
public int MinMemoryMb { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum memory in MB.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxMemoryMb")]
|
||||
public int MaxMemoryMb { get; init; } = 512;
|
||||
|
||||
/// <summary>
|
||||
/// CPU allocation (percentage or cores).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cpuLimit")]
|
||||
public string? CpuLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk space required in MB.
|
||||
/// </summary>
|
||||
[JsonPropertyName("diskMb")]
|
||||
public int DiskMb { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether network access is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("networkRequired")]
|
||||
public bool NetworkRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether GPU access is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gpuRequired")]
|
||||
public bool GpuRequired { get; init; }
|
||||
}
|
||||
159
src/Plugin/StellaOps.Plugin.Abstractions/PluginCapabilities.cs
Normal file
159
src/Plugin/StellaOps.Plugin.Abstractions/PluginCapabilities.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
namespace StellaOps.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Flags indicating plugin capabilities. Plugins may provide multiple capabilities.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PluginCapabilities : long
|
||||
{
|
||||
/// <summary>No capabilities declared.</summary>
|
||||
None = 0,
|
||||
|
||||
// ========== Core Platform Capabilities (bits 0-9) ==========
|
||||
|
||||
/// <summary>Cryptographic operations (signing, verification, encryption).</summary>
|
||||
Crypto = 1L << 0,
|
||||
|
||||
/// <summary>Authentication and authorization.</summary>
|
||||
Auth = 1L << 1,
|
||||
|
||||
/// <summary>Large language model inference.</summary>
|
||||
Llm = 1L << 2,
|
||||
|
||||
/// <summary>General secret management.</summary>
|
||||
Secrets = 1L << 3,
|
||||
|
||||
// ========== Connector Capabilities (bits 10-19) ==========
|
||||
|
||||
/// <summary>Source control management (GitHub, GitLab, etc.).</summary>
|
||||
Scm = 1L << 10,
|
||||
|
||||
/// <summary>Container registry operations.</summary>
|
||||
Registry = 1L << 11,
|
||||
|
||||
/// <summary>Continuous integration systems.</summary>
|
||||
Ci = 1L << 12,
|
||||
|
||||
/// <summary>Secret vault integration (HashiCorp, Azure KeyVault, etc.).</summary>
|
||||
Vault = 1L << 13,
|
||||
|
||||
/// <summary>Notification delivery (email, Slack, Teams, webhooks).</summary>
|
||||
Notification = 1L << 14,
|
||||
|
||||
/// <summary>Issue tracking systems (Jira, GitHub Issues, etc.).</summary>
|
||||
IssueTracker = 1L << 15,
|
||||
|
||||
// ========== Analysis Capabilities (bits 20-29) ==========
|
||||
|
||||
/// <summary>Source code or binary analysis.</summary>
|
||||
Analysis = 1L << 20,
|
||||
|
||||
/// <summary>Vulnerability feed ingestion.</summary>
|
||||
Feed = 1L << 21,
|
||||
|
||||
/// <summary>SBOM generation or parsing.</summary>
|
||||
Sbom = 1L << 22,
|
||||
|
||||
// ========== Infrastructure Capabilities (bits 30-39) ==========
|
||||
|
||||
/// <summary>Message transport (TCP, UDP, AMQP, etc.).</summary>
|
||||
Transport = 1L << 30,
|
||||
|
||||
/// <summary>Network access required.</summary>
|
||||
Network = 1L << 31,
|
||||
|
||||
/// <summary>Filesystem read access.</summary>
|
||||
FilesystemRead = 1L << 32,
|
||||
|
||||
/// <summary>Filesystem write access.</summary>
|
||||
FilesystemWrite = 1L << 33,
|
||||
|
||||
/// <summary>Process spawning.</summary>
|
||||
Process = 1L << 34,
|
||||
|
||||
// ========== Orchestrator Capabilities (bits 40-49) ==========
|
||||
|
||||
/// <summary>Workflow step provider.</summary>
|
||||
StepProvider = 1L << 40,
|
||||
|
||||
/// <summary>Promotion gate provider.</summary>
|
||||
GateProvider = 1L << 41,
|
||||
|
||||
/// <summary>Deployment target provider.</summary>
|
||||
TargetProvider = 1L << 42,
|
||||
|
||||
/// <summary>Evidence collector.</summary>
|
||||
EvidenceProvider = 1L << 43,
|
||||
|
||||
// ========== Composite Capabilities ==========
|
||||
|
||||
/// <summary>All connector capabilities.</summary>
|
||||
AllConnectors = Scm | Registry | Ci | Vault | Notification | IssueTracker,
|
||||
|
||||
/// <summary>All orchestrator capabilities.</summary>
|
||||
AllOrchestrator = StepProvider | GateProvider | TargetProvider | EvidenceProvider,
|
||||
|
||||
/// <summary>All infrastructure capabilities.</summary>
|
||||
AllInfrastructure = Transport | Network | FilesystemRead | FilesystemWrite | Process
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="PluginCapabilities"/>.
|
||||
/// </summary>
|
||||
public static class PluginCapabilitiesExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the plugin has the specified capability.
|
||||
/// </summary>
|
||||
public static bool Has(this PluginCapabilities capabilities, PluginCapabilities capability) =>
|
||||
(capabilities & capability) == capability;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin has any of the specified capabilities.
|
||||
/// </summary>
|
||||
public static bool HasAny(this PluginCapabilities capabilities, PluginCapabilities any) =>
|
||||
(capabilities & any) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Converts capabilities to string array for database storage.
|
||||
/// </summary>
|
||||
public static string[] ToStringArray(this PluginCapabilities capabilities)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (PluginCapabilities value in Enum.GetValues<PluginCapabilities>())
|
||||
{
|
||||
if (value != PluginCapabilities.None &&
|
||||
!value.ToString().StartsWith("All", StringComparison.Ordinal) &&
|
||||
capabilities.Has(value))
|
||||
{
|
||||
result.Add(value.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses capability strings back to flags.
|
||||
/// </summary>
|
||||
public static PluginCapabilities FromStringArray(string[] capabilities)
|
||||
{
|
||||
var result = PluginCapabilities.None;
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
if (Enum.TryParse<PluginCapabilities>(cap, ignoreCase: true, out var parsed))
|
||||
{
|
||||
result |= parsed;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable list of capabilities.
|
||||
/// </summary>
|
||||
public static string ToDisplayString(this PluginCapabilities capabilities)
|
||||
{
|
||||
var parts = capabilities.ToStringArray();
|
||||
return parts.Length == 0 ? "None" : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
75
src/Plugin/StellaOps.Plugin.Abstractions/PluginInfo.cs
Normal file
75
src/Plugin/StellaOps.Plugin.Abstractions/PluginInfo.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable metadata identifying a plugin.
|
||||
/// </summary>
|
||||
/// <param name="Id">
|
||||
/// Reverse domain notation identifier, e.g., "com.stellaops.crypto.gost".
|
||||
/// Must be unique across all plugins.
|
||||
/// </param>
|
||||
/// <param name="Name">Human-readable display name.</param>
|
||||
/// <param name="Version">Semantic version string (Major.Minor.Patch[-PreRelease]).</param>
|
||||
/// <param name="Vendor">Organization or individual that created the plugin.</param>
|
||||
/// <param name="Description">Optional description of plugin functionality.</param>
|
||||
/// <param name="LicenseId">Optional SPDX license identifier.</param>
|
||||
/// <param name="ProjectUrl">Optional URL to project homepage or repository.</param>
|
||||
/// <param name="IconUrl">Optional URL to plugin icon (64x64 PNG recommended).</param>
|
||||
public sealed partial record PluginInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Version,
|
||||
string Vendor,
|
||||
string? Description = null,
|
||||
string? LicenseId = null,
|
||||
string? ProjectUrl = null,
|
||||
string? IconUrl = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the plugin info and throws if invalid.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">If any required field is invalid.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Id))
|
||||
throw new ArgumentException("Plugin ID is required", nameof(Id));
|
||||
|
||||
if (!PluginIdPattern().IsMatch(Id))
|
||||
throw new ArgumentException(
|
||||
"Plugin ID must be reverse domain notation (e.g., com.example.myplugin)",
|
||||
nameof(Id));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
throw new ArgumentException("Plugin name is required", nameof(Name));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Version))
|
||||
throw new ArgumentException("Plugin version is required", nameof(Version));
|
||||
|
||||
if (!SemVerPattern().IsMatch(Version))
|
||||
throw new ArgumentException(
|
||||
"Plugin version must be valid SemVer (e.g., 1.0.0 or 1.0.0-beta.1)",
|
||||
nameof(Version));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Vendor))
|
||||
throw new ArgumentException("Plugin vendor is required", nameof(Vendor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the version string into a comparable <see cref="System.Version"/>.
|
||||
/// Pre-release suffixes are stripped for comparison.
|
||||
/// </summary>
|
||||
public System.Version ParsedVersion =>
|
||||
System.Version.Parse(Version.Split('-')[0]);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a qualified identifier combining ID and version.
|
||||
/// </summary>
|
||||
public string QualifiedId => $"{Id}@{Version}";
|
||||
|
||||
[GeneratedRegex(@"^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$", RegexOptions.Compiled)]
|
||||
private static partial Regex PluginIdPattern();
|
||||
|
||||
[GeneratedRegex(@"^\d+\.\d+\.\d+(-[a-zA-Z0-9\.]+)?$", RegexOptions.Compiled)]
|
||||
private static partial Regex SemVerPattern();
|
||||
}
|
||||
77
src/Plugin/StellaOps.Plugin.Abstractions/PluginTrustLevel.cs
Normal file
77
src/Plugin/StellaOps.Plugin.Abstractions/PluginTrustLevel.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
namespace StellaOps.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Trust level determines plugin execution environment and permissions.
|
||||
/// </summary>
|
||||
public enum PluginTrustLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin is bundled with the platform and fully trusted.
|
||||
/// Executes in-process with full access to platform internals.
|
||||
/// No sandboxing or resource limits applied.
|
||||
/// </summary>
|
||||
BuiltIn = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is signed by a trusted vendor.
|
||||
/// Executes with moderate isolation (AssemblyLoadContext).
|
||||
/// Soft resource limits applied, behavior monitored.
|
||||
/// </summary>
|
||||
Trusted = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is from an unknown or untrusted source.
|
||||
/// Executes in isolated process with full sandboxing.
|
||||
/// Hard resource limits, network restrictions, filesystem isolation.
|
||||
/// Communication via gRPC over Unix domain socket.
|
||||
/// </summary>
|
||||
Untrusted = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="PluginTrustLevel"/>.
|
||||
/// </summary>
|
||||
public static class PluginTrustLevelExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the plugin requires process isolation.
|
||||
/// </summary>
|
||||
public static bool RequiresProcessIsolation(this PluginTrustLevel level) =>
|
||||
level == PluginTrustLevel.Untrusted;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin should have resource limits enforced.
|
||||
/// </summary>
|
||||
public static bool HasResourceLimits(this PluginTrustLevel level) =>
|
||||
level >= PluginTrustLevel.Trusted;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the plugin can access platform internals directly.
|
||||
/// </summary>
|
||||
public static bool CanAccessInternals(this PluginTrustLevel level) =>
|
||||
level == PluginTrustLevel.BuiltIn;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the string representation suitable for database storage.
|
||||
/// </summary>
|
||||
public static string ToStorageString(this PluginTrustLevel level) =>
|
||||
level switch
|
||||
{
|
||||
PluginTrustLevel.BuiltIn => "builtin",
|
||||
PluginTrustLevel.Trusted => "trusted",
|
||||
PluginTrustLevel.Untrusted => "untrusted",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(level))
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses a storage string back to enum value.
|
||||
/// </summary>
|
||||
public static PluginTrustLevel ParseFromStorage(string value) =>
|
||||
value.ToLowerInvariant() switch
|
||||
{
|
||||
"builtin" => PluginTrustLevel.BuiltIn,
|
||||
"trusted" => PluginTrustLevel.Trusted,
|
||||
"untrusted" => PluginTrustLevel.Untrusted,
|
||||
_ => throw new ArgumentException($"Invalid trust level: {value}", nameof(value))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<!-- Package metadata -->
|
||||
<PackageId>StellaOps.Plugin.Abstractions</PackageId>
|
||||
<Description>Core plugin abstractions for the Stella Ops platform. Defines IPlugin interface, capability interfaces, and plugin lifecycle contracts.</Description>
|
||||
<Authors>Stella Ops</Authors>
|
||||
<Company>Stella Ops</Company>
|
||||
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
221
src/Plugin/StellaOps.Plugin.Host/Context/PluginConfiguration.cs
Normal file
221
src/Plugin/StellaOps.Plugin.Host/Context/PluginConfiguration.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginConfiguration.
|
||||
/// </summary>
|
||||
public sealed class PluginConfiguration : IPluginConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, object> _values;
|
||||
private readonly PluginManifest _manifest;
|
||||
private readonly Func<string, CancellationToken, Task<string?>>? _secretProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin configuration.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest (contains default config).</param>
|
||||
/// <param name="overrides">Optional configuration overrides.</param>
|
||||
/// <param name="secretProvider">Optional secret provider function.</param>
|
||||
public PluginConfiguration(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, object>? overrides = null,
|
||||
Func<string, CancellationToken, Task<string?>>? secretProvider = null)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_secretProvider = secretProvider;
|
||||
_values = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Load defaults from manifest
|
||||
if (manifest.DefaultConfig != null)
|
||||
{
|
||||
LoadFromJsonDocument(manifest.DefaultConfig);
|
||||
}
|
||||
|
||||
// Apply overrides
|
||||
if (overrides != null)
|
||||
{
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
_values[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetValue<T>(string key, T? defaultValue = default)
|
||||
{
|
||||
if (!_values.TryGetValue(key, out var value))
|
||||
return defaultValue;
|
||||
|
||||
var converted = ConvertValue<T>(value);
|
||||
return converted ?? defaultValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Bind<T>(string? sectionKey = null) where T : class, new()
|
||||
{
|
||||
var result = new T();
|
||||
var prefix = string.IsNullOrEmpty(sectionKey) ? "" : sectionKey + ":";
|
||||
var type = typeof(T);
|
||||
|
||||
foreach (var property in type.GetProperties())
|
||||
{
|
||||
if (!property.CanWrite)
|
||||
continue;
|
||||
|
||||
var key = string.IsNullOrEmpty(prefix) ? property.Name : $"{prefix}{property.Name}";
|
||||
|
||||
if (_values.TryGetValue(key, out var value))
|
||||
{
|
||||
try
|
||||
{
|
||||
var converted = ConvertValueToType(value, property.PropertyType);
|
||||
if (converted != null)
|
||||
{
|
||||
property.SetValue(result, converted);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip properties that can't be converted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
|
||||
{
|
||||
if (_secretProvider != null)
|
||||
{
|
||||
return await _secretProvider(secretName, ct);
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
return Environment.GetEnvironmentVariable(secretName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasKey(string key)
|
||||
{
|
||||
return _values.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all configuration values.
|
||||
/// </summary>
|
||||
/// <returns>All configuration values.</returns>
|
||||
public IReadOnlyDictionary<string, object> GetAll()
|
||||
{
|
||||
return _values;
|
||||
}
|
||||
|
||||
private void LoadFromJsonDocument(JsonDocument document)
|
||||
{
|
||||
LoadFromJsonElement("", document.RootElement);
|
||||
}
|
||||
|
||||
private void LoadFromJsonElement(string prefix, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix)
|
||||
? property.Name
|
||||
: $"{prefix}:{property.Name}";
|
||||
LoadFromJsonElement(key, property.Value);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
var index = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var key = $"{prefix}:{index}";
|
||||
LoadFromJsonElement(key, item);
|
||||
index++;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
_values[prefix] = element.GetString()!;
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
_values[prefix] = longValue;
|
||||
else if (element.TryGetDouble(out var doubleValue))
|
||||
_values[prefix] = doubleValue;
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
_values[prefix] = true;
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
_values[prefix] = false;
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
case JsonValueKind.Undefined:
|
||||
// Skip null values
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static T? ConvertValue<T>(object value)
|
||||
{
|
||||
return (T?)ConvertValueToType(value, typeof(T));
|
||||
}
|
||||
|
||||
private static object? ConvertValueToType(object value, Type targetType)
|
||||
{
|
||||
if (value.GetType() == targetType)
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
var json = jsonElement.GetRawText();
|
||||
return JsonSerializer.Deserialize(json, targetType);
|
||||
}
|
||||
|
||||
if (underlyingType == typeof(string))
|
||||
return value.ToString();
|
||||
|
||||
if (underlyingType == typeof(int))
|
||||
return Convert.ToInt32(value);
|
||||
|
||||
if (underlyingType == typeof(long))
|
||||
return Convert.ToInt64(value);
|
||||
|
||||
if (underlyingType == typeof(double))
|
||||
return Convert.ToDouble(value);
|
||||
|
||||
if (underlyingType == typeof(bool))
|
||||
return Convert.ToBoolean(value);
|
||||
|
||||
if (underlyingType == typeof(TimeSpan) && value is string strValue)
|
||||
return TimeSpan.Parse(strValue);
|
||||
|
||||
if (underlyingType == typeof(Guid) && value is string guidStr)
|
||||
return Guid.Parse(guidStr);
|
||||
|
||||
return Convert.ChangeType(value, underlyingType);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/Plugin/StellaOps.Plugin.Host/Context/PluginContext.cs
Normal file
130
src/Plugin/StellaOps.Plugin.Host/Context/PluginContext.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginContext provided to plugins during initialization.
|
||||
/// </summary>
|
||||
public sealed class PluginContext : IPluginContext
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger Logger { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginServices Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? TenantId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken ShutdownToken { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The plugin manifest.
|
||||
/// </summary>
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level of the plugin.
|
||||
/// </summary>
|
||||
public PluginTrustLevel TrustLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin context.
|
||||
/// </summary>
|
||||
public PluginContext(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
IPluginConfiguration configuration,
|
||||
IPluginLogger logger,
|
||||
IPluginServices services,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken shutdownToken,
|
||||
Guid? tenantId = null)
|
||||
{
|
||||
Manifest = manifest;
|
||||
TrustLevel = trustLevel;
|
||||
Configuration = configuration;
|
||||
Logger = logger;
|
||||
Services = services;
|
||||
TimeProvider = timeProvider;
|
||||
ShutdownToken = shutdownToken;
|
||||
TenantId = tenantId;
|
||||
InstanceId = Guid.NewGuid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating plugin contexts.
|
||||
/// </summary>
|
||||
public sealed class PluginContextFactory
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin context factory.
|
||||
/// </summary>
|
||||
public PluginContextFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
PluginHostOptions options,
|
||||
IServiceProvider serviceProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_options = options;
|
||||
_serviceProvider = serviceProvider;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a plugin context for a manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="trustLevel">Trust level.</param>
|
||||
/// <param name="shutdownToken">Shutdown token.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <returns>The created context.</returns>
|
||||
public PluginContext Create(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken shutdownToken,
|
||||
Guid? tenantId = null)
|
||||
{
|
||||
var pluginId = manifest.Info.Id;
|
||||
|
||||
// Create configuration
|
||||
var configuration = new PluginConfiguration(manifest);
|
||||
|
||||
// Create logger
|
||||
var innerLogger = _loggerFactory.CreateLogger($"Plugin.{pluginId}");
|
||||
var logger = new PluginLogger(innerLogger, pluginId);
|
||||
|
||||
// Create services wrapper
|
||||
var services = new PluginServices(_serviceProvider, trustLevel);
|
||||
|
||||
return new PluginContext(
|
||||
manifest,
|
||||
trustLevel,
|
||||
configuration,
|
||||
logger,
|
||||
services,
|
||||
_timeProvider,
|
||||
shutdownToken,
|
||||
tenantId);
|
||||
}
|
||||
}
|
||||
112
src/Plugin/StellaOps.Plugin.Host/Context/PluginLogger.cs
Normal file
112
src/Plugin/StellaOps.Plugin.Host/Context/PluginLogger.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginLogger that wraps a Microsoft.Extensions.Logging logger.
|
||||
/// </summary>
|
||||
public sealed class PluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _pluginId;
|
||||
private readonly Dictionary<string, object> _properties;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin logger.
|
||||
/// </summary>
|
||||
/// <param name="logger">The underlying logger.</param>
|
||||
/// <param name="pluginId">The plugin ID for context.</param>
|
||||
public PluginLogger(ILogger logger, string pluginId)
|
||||
: this(logger, pluginId, new Dictionary<string, object>())
|
||||
{
|
||||
}
|
||||
|
||||
private PluginLogger(ILogger logger, string pluginId, Dictionary<string, object> properties)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginId = pluginId;
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
_logger.Log(level, message, args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
_logger.Log(level, exception, message, args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProperties = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
|
||||
// Create a logger that includes the property in scope
|
||||
return new PropertyScopedPluginLogger(_logger, _pluginId, newProperties);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return WithProperty("Operation", operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel level) => _logger.IsEnabled(level);
|
||||
|
||||
/// <summary>
|
||||
/// Plugin logger with property scope support.
|
||||
/// </summary>
|
||||
private sealed class PropertyScopedPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _pluginId;
|
||||
private readonly Dictionary<string, object> _properties;
|
||||
|
||||
public PropertyScopedPluginLogger(
|
||||
ILogger logger,
|
||||
string pluginId,
|
||||
Dictionary<string, object> properties)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginId = pluginId;
|
||||
_properties = properties;
|
||||
}
|
||||
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
using var scope = _logger.BeginScope(_properties);
|
||||
_logger.Log(level, message, args);
|
||||
}
|
||||
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
using var scope = _logger.BeginScope(_properties);
|
||||
_logger.Log(level, exception, message, args);
|
||||
}
|
||||
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProperties = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
return new PropertyScopedPluginLogger(_logger, _pluginId, newProperties);
|
||||
}
|
||||
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return WithProperty("Operation", operationName);
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel level) => _logger.IsEnabled(level);
|
||||
}
|
||||
}
|
||||
120
src/Plugin/StellaOps.Plugin.Host/Context/PluginServices.cs
Normal file
120
src/Plugin/StellaOps.Plugin.Host/Context/PluginServices.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of IPluginServices that provides access to platform services.
|
||||
/// </summary>
|
||||
public sealed class PluginServices : IPluginServices, IAsyncDisposable
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly PluginTrustLevel _trustLevel;
|
||||
private readonly HashSet<Type> _restrictedTypes;
|
||||
private readonly IServiceScope? _scope;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin services wrapper.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The underlying service provider.</param>
|
||||
/// <param name="trustLevel">Trust level of the plugin.</param>
|
||||
public PluginServices(IServiceProvider serviceProvider, PluginTrustLevel trustLevel)
|
||||
: this(serviceProvider, trustLevel, null)
|
||||
{
|
||||
}
|
||||
|
||||
private PluginServices(IServiceProvider serviceProvider, PluginTrustLevel trustLevel, IServiceScope? scope)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_trustLevel = trustLevel;
|
||||
_scope = scope;
|
||||
_restrictedTypes = GetRestrictedTypes(trustLevel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetService<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetService<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T GetRequiredService<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetRequiredService<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<T> GetServices<T>() where T : class
|
||||
{
|
||||
ValidateAccess(typeof(T));
|
||||
return _serviceProvider.GetServices<T>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncDisposable CreateScope(out IPluginServices scopedServices)
|
||||
{
|
||||
var scope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
|
||||
var scoped = new PluginServices(scope.ServiceProvider, _trustLevel, scope);
|
||||
scopedServices = scoped;
|
||||
return scoped;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scope is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateAccess(Type serviceType)
|
||||
{
|
||||
if (IsRestricted(serviceType))
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Plugin does not have permission to access service '{serviceType.Name}'. " +
|
||||
$"Trust level: {_trustLevel}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRestricted(Type serviceType)
|
||||
{
|
||||
// Built-in plugins have full access
|
||||
if (_trustLevel == PluginTrustLevel.BuiltIn)
|
||||
return false;
|
||||
|
||||
// Check if the type or any of its interfaces are restricted
|
||||
if (_restrictedTypes.Contains(serviceType))
|
||||
return true;
|
||||
|
||||
foreach (var iface in serviceType.GetInterfaces())
|
||||
{
|
||||
if (_restrictedTypes.Contains(iface))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HashSet<Type> GetRestrictedTypes(PluginTrustLevel trustLevel)
|
||||
{
|
||||
var restricted = new HashSet<Type>();
|
||||
|
||||
// Untrusted plugins have more restrictions
|
||||
if (trustLevel == PluginTrustLevel.Untrusted)
|
||||
{
|
||||
// Add types that untrusted plugins cannot access
|
||||
// This will be populated based on security requirements
|
||||
}
|
||||
|
||||
return restricted;
|
||||
}
|
||||
}
|
||||
225
src/Plugin/StellaOps.Plugin.Host/Dependencies/DependencyGraph.cs
Normal file
225
src/Plugin/StellaOps.Plugin.Host/Dependencies/DependencyGraph.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a directed graph of plugin dependencies.
|
||||
/// </summary>
|
||||
public sealed class DependencyGraph
|
||||
{
|
||||
private readonly HashSet<string> _nodes = [];
|
||||
private readonly Dictionary<string, HashSet<string>> _edges = new();
|
||||
private readonly Dictionary<string, HashSet<string>> _reverseEdges = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all nodes in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> Nodes => _nodes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of nodes in the graph.
|
||||
/// </summary>
|
||||
public int NodeCount => _nodes.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of edges in the graph.
|
||||
/// </summary>
|
||||
public int EdgeCount => _edges.Values.Sum(e => e.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a node to the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
public void AddNode(string node)
|
||||
{
|
||||
if (_nodes.Add(node))
|
||||
{
|
||||
_edges[node] = [];
|
||||
_reverseEdges[node] = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a node exists in the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>True if the node exists.</returns>
|
||||
public bool HasNode(string node)
|
||||
{
|
||||
return _nodes.Contains(node);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from one node to another.
|
||||
/// </summary>
|
||||
/// <param name="from">The source node.</param>
|
||||
/// <param name="to">The target node.</param>
|
||||
public void AddEdge(string from, string to)
|
||||
{
|
||||
AddNode(from);
|
||||
AddNode(to);
|
||||
|
||||
_edges[from].Add(to);
|
||||
_reverseEdges[to].Add(from);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nodes that depend on a given node (outgoing edges).
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of dependent node IDs.</returns>
|
||||
public IReadOnlySet<string> GetDependents(string node)
|
||||
{
|
||||
return _edges.TryGetValue(node, out var dependents) ? dependents : new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nodes that a given node depends on (incoming edges).
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of dependency node IDs.</returns>
|
||||
public IReadOnlySet<string> GetDependencies(string node)
|
||||
{
|
||||
return _reverseEdges.TryGetValue(node, out var dependencies) ? dependencies : new HashSet<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets nodes with no dependencies (roots of the dependency tree).
|
||||
/// </summary>
|
||||
/// <returns>Set of root node IDs.</returns>
|
||||
public IReadOnlySet<string> GetRoots()
|
||||
{
|
||||
return _nodes
|
||||
.Where(n => !_reverseEdges.TryGetValue(n, out var deps) || deps.Count == 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets nodes with no dependents (leaves of the dependency tree).
|
||||
/// </summary>
|
||||
/// <returns>Set of leaf node IDs.</returns>
|
||||
public IReadOnlySet<string> GetLeaves()
|
||||
{
|
||||
return _nodes
|
||||
.Where(n => !_edges.TryGetValue(n, out var deps) || deps.Count == 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all transitive dependencies for a node.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of all transitive dependency IDs.</returns>
|
||||
public IReadOnlySet<string> GetAllDependencies(string node)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var dep in GetDependencies(node))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (result.Add(current))
|
||||
{
|
||||
foreach (var dep in GetDependencies(current))
|
||||
{
|
||||
if (!result.Contains(dep))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all transitive dependents for a node.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
/// <returns>Set of all transitive dependent IDs.</returns>
|
||||
public IReadOnlySet<string> GetAllDependents(string node)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var dep in GetDependents(node))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (result.Add(current))
|
||||
{
|
||||
foreach (var dep in GetDependents(current))
|
||||
{
|
||||
if (!result.Contains(dep))
|
||||
{
|
||||
queue.Enqueue(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a node and all its edges from the graph.
|
||||
/// </summary>
|
||||
/// <param name="node">The node ID.</param>
|
||||
public void RemoveNode(string node)
|
||||
{
|
||||
if (!_nodes.Remove(node))
|
||||
return;
|
||||
|
||||
// Remove outgoing edges
|
||||
if (_edges.TryGetValue(node, out var outgoing))
|
||||
{
|
||||
foreach (var target in outgoing)
|
||||
{
|
||||
_reverseEdges[target].Remove(node);
|
||||
}
|
||||
_edges.Remove(node);
|
||||
}
|
||||
|
||||
// Remove incoming edges
|
||||
if (_reverseEdges.TryGetValue(node, out var incoming))
|
||||
{
|
||||
foreach (var source in incoming)
|
||||
{
|
||||
_edges[source].Remove(node);
|
||||
}
|
||||
_reverseEdges.Remove(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of the graph.
|
||||
/// </summary>
|
||||
/// <returns>A new graph with the same nodes and edges.</returns>
|
||||
public DependencyGraph Clone()
|
||||
{
|
||||
var clone = new DependencyGraph();
|
||||
|
||||
foreach (var node in _nodes)
|
||||
{
|
||||
clone.AddNode(node);
|
||||
}
|
||||
|
||||
foreach (var kvp in _edges)
|
||||
{
|
||||
foreach (var target in kvp.Value)
|
||||
{
|
||||
clone.AddEdge(kvp.Key, target);
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves plugin dependencies and determines load order.
|
||||
/// </summary>
|
||||
public interface IPluginDependencyResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the order in which plugins should be loaded based on dependencies.
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests to order.</param>
|
||||
/// <returns>Manifests in load order (dependencies first).</returns>
|
||||
IReadOnlyList<PluginManifest> ResolveLoadOrder(IEnumerable<PluginManifest> manifests);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the order in which plugins should be unloaded (reverse of load order).
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests to order.</param>
|
||||
/// <returns>Plugin IDs in unload order (dependents first).</returns>
|
||||
IReadOnlyList<string> ResolveUnloadOrder(IEnumerable<PluginManifest?> manifests);
|
||||
|
||||
/// <summary>
|
||||
/// Check if all dependencies for a plugin are satisfied.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="availablePlugins">Available plugin IDs and versions.</param>
|
||||
/// <returns>True if all required dependencies are available.</returns>
|
||||
bool AreDependenciesSatisfied(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins);
|
||||
|
||||
/// <summary>
|
||||
/// Get missing dependencies for a plugin.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="availablePlugins">Available plugin IDs and versions.</param>
|
||||
/// <returns>List of missing dependencies.</returns>
|
||||
IReadOnlyList<MissingDependency> GetMissingDependencies(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins);
|
||||
|
||||
/// <summary>
|
||||
/// Validate the dependency graph for circular dependencies.
|
||||
/// </summary>
|
||||
/// <param name="manifests">The plugin manifests.</param>
|
||||
/// <returns>List of circular dependency errors, empty if valid.</returns>
|
||||
IReadOnlyList<CircularDependencyError> ValidateDependencyGraph(IEnumerable<PluginManifest> manifests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a missing plugin dependency.
|
||||
/// </summary>
|
||||
/// <param name="PluginId">ID of the plugin requiring the dependency.</param>
|
||||
/// <param name="RequiredPluginId">ID of the missing plugin.</param>
|
||||
/// <param name="RequiredVersion">Required version constraint.</param>
|
||||
/// <param name="IsOptional">Whether the dependency is optional.</param>
|
||||
public sealed record MissingDependency(
|
||||
string PluginId,
|
||||
string RequiredPluginId,
|
||||
string? RequiredVersion,
|
||||
bool IsOptional);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a circular dependency error.
|
||||
/// </summary>
|
||||
/// <param name="Cycle">The plugins involved in the circular dependency.</param>
|
||||
public sealed record CircularDependencyError(IReadOnlyList<string> Cycle)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the cycle.
|
||||
/// </summary>
|
||||
public string Description => string.Join(" -> ", Cycle) + " -> " + Cycle[0];
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Dependencies;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves plugin dependencies using topological sorting.
|
||||
/// </summary>
|
||||
public sealed class PluginDependencyResolver : IPluginDependencyResolver
|
||||
{
|
||||
private readonly ILogger<PluginDependencyResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin dependency resolver.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public PluginDependencyResolver(ILogger<PluginDependencyResolver> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PluginManifest> ResolveLoadOrder(IEnumerable<PluginManifest> manifests)
|
||||
{
|
||||
var manifestList = manifests.ToList();
|
||||
var graph = BuildDependencyGraph(manifestList);
|
||||
|
||||
// Validate for cycles
|
||||
var cycles = DetectCycles(graph);
|
||||
if (cycles.Count > 0)
|
||||
{
|
||||
var cycleDescriptions = string.Join("; ", cycles.Select(c => c.Description));
|
||||
throw new InvalidOperationException($"Circular dependencies detected: {cycleDescriptions}");
|
||||
}
|
||||
|
||||
// Topological sort
|
||||
var sorted = TopologicalSort(graph);
|
||||
|
||||
// Map back to manifests
|
||||
var manifestLookup = manifestList.ToDictionary(m => m.Info.Id);
|
||||
var result = new List<PluginManifest>();
|
||||
|
||||
foreach (var pluginId in sorted)
|
||||
{
|
||||
if (manifestLookup.TryGetValue(pluginId, out var manifest))
|
||||
{
|
||||
result.Add(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Resolved load order for {Count} plugins", result.Count);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> ResolveUnloadOrder(IEnumerable<PluginManifest?> manifests)
|
||||
{
|
||||
var validManifests = manifests.Where(m => m != null).Cast<PluginManifest>().ToList();
|
||||
var loadOrder = ResolveLoadOrder(validManifests);
|
||||
|
||||
// Reverse the load order to get unload order
|
||||
return loadOrder.Select(m => m.Info.Id).Reverse().ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AreDependenciesSatisfied(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins)
|
||||
{
|
||||
var missing = GetMissingDependencies(manifest, availablePlugins);
|
||||
return missing.All(m => m.IsOptional);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MissingDependency> GetMissingDependencies(
|
||||
PluginManifest manifest,
|
||||
IReadOnlyDictionary<string, string> availablePlugins)
|
||||
{
|
||||
var missing = new List<MissingDependency>();
|
||||
|
||||
foreach (var dependency in manifest.Dependencies)
|
||||
{
|
||||
if (!availablePlugins.TryGetValue(dependency.PluginId, out var availableVersion))
|
||||
{
|
||||
missing.Add(new MissingDependency(
|
||||
manifest.Info.Id,
|
||||
dependency.PluginId,
|
||||
dependency.VersionConstraint,
|
||||
dependency.Optional));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check version constraint if specified
|
||||
if (!string.IsNullOrEmpty(dependency.VersionConstraint))
|
||||
{
|
||||
if (!SatisfiesVersionConstraint(availableVersion, dependency.VersionConstraint))
|
||||
{
|
||||
missing.Add(new MissingDependency(
|
||||
manifest.Info.Id,
|
||||
dependency.PluginId,
|
||||
dependency.VersionConstraint,
|
||||
dependency.Optional));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<CircularDependencyError> ValidateDependencyGraph(IEnumerable<PluginManifest> manifests)
|
||||
{
|
||||
var graph = BuildDependencyGraph(manifests.ToList());
|
||||
return DetectCycles(graph);
|
||||
}
|
||||
|
||||
private static DependencyGraph BuildDependencyGraph(IReadOnlyList<PluginManifest> manifests)
|
||||
{
|
||||
var graph = new DependencyGraph();
|
||||
|
||||
// Add all nodes
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
graph.AddNode(manifest.Info.Id);
|
||||
}
|
||||
|
||||
// Add edges for dependencies
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
foreach (var dependency in manifest.Dependencies)
|
||||
{
|
||||
// Only add edge if the dependency exists in the graph
|
||||
if (graph.HasNode(dependency.PluginId))
|
||||
{
|
||||
// Edge goes from dependency to dependent (dependency must load first)
|
||||
graph.AddEdge(dependency.PluginId, manifest.Info.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CircularDependencyError> DetectCycles(DependencyGraph graph)
|
||||
{
|
||||
var cycles = new List<CircularDependencyError>();
|
||||
var visited = new HashSet<string>();
|
||||
var recursionStack = new HashSet<string>();
|
||||
var path = new List<string>();
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!visited.Contains(node))
|
||||
{
|
||||
DetectCyclesDfs(node, graph, visited, recursionStack, path, cycles);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
private static void DetectCyclesDfs(
|
||||
string node,
|
||||
DependencyGraph graph,
|
||||
HashSet<string> visited,
|
||||
HashSet<string> recursionStack,
|
||||
List<string> path,
|
||||
List<CircularDependencyError> cycles)
|
||||
{
|
||||
visited.Add(node);
|
||||
recursionStack.Add(node);
|
||||
path.Add(node);
|
||||
|
||||
foreach (var neighbor in graph.GetDependents(node))
|
||||
{
|
||||
if (!visited.Contains(neighbor))
|
||||
{
|
||||
DetectCyclesDfs(neighbor, graph, visited, recursionStack, path, cycles);
|
||||
}
|
||||
else if (recursionStack.Contains(neighbor))
|
||||
{
|
||||
// Found a cycle - extract the cycle from the path
|
||||
var cycleStart = path.IndexOf(neighbor);
|
||||
var cycle = path.Skip(cycleStart).ToList();
|
||||
cycles.Add(new CircularDependencyError(cycle));
|
||||
}
|
||||
}
|
||||
|
||||
path.RemoveAt(path.Count - 1);
|
||||
recursionStack.Remove(node);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TopologicalSort(DependencyGraph graph)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var visited = new HashSet<string>();
|
||||
var tempVisited = new HashSet<string>();
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!visited.Contains(node))
|
||||
{
|
||||
TopologicalSortVisit(node, graph, visited, tempVisited, result);
|
||||
}
|
||||
}
|
||||
|
||||
result.Reverse(); // Reverse to get correct order
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TopologicalSortVisit(
|
||||
string node,
|
||||
DependencyGraph graph,
|
||||
HashSet<string> visited,
|
||||
HashSet<string> tempVisited,
|
||||
List<string> result)
|
||||
{
|
||||
if (tempVisited.Contains(node))
|
||||
return; // Cycle detected, but we handle this separately
|
||||
|
||||
if (visited.Contains(node))
|
||||
return;
|
||||
|
||||
tempVisited.Add(node);
|
||||
|
||||
foreach (var neighbor in graph.GetDependents(node))
|
||||
{
|
||||
TopologicalSortVisit(neighbor, graph, visited, tempVisited, result);
|
||||
}
|
||||
|
||||
tempVisited.Remove(node);
|
||||
visited.Add(node);
|
||||
result.Add(node);
|
||||
}
|
||||
|
||||
private static bool SatisfiesVersionConstraint(string version, string constraint)
|
||||
{
|
||||
// Simple version constraint parsing
|
||||
// Supports: >=1.0.0, >1.0.0, =1.0.0, <2.0.0, <=2.0.0, ~1.0.0 (pessimistic), ^1.0.0 (compatible)
|
||||
|
||||
if (string.IsNullOrEmpty(constraint))
|
||||
return true;
|
||||
|
||||
constraint = constraint.Trim();
|
||||
|
||||
if (!Version.TryParse(version.TrimStart('v', 'V'), out var actualVersion))
|
||||
return false;
|
||||
|
||||
// Handle operators
|
||||
if (constraint.StartsWith(">="))
|
||||
{
|
||||
var required = ParseVersion(constraint[2..]);
|
||||
return required != null && actualVersion >= required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("<="))
|
||||
{
|
||||
var required = ParseVersion(constraint[2..]);
|
||||
return required != null && actualVersion <= required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith(">"))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion > required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("<"))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion < required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("="))
|
||||
{
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
return required != null && actualVersion == required;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("~"))
|
||||
{
|
||||
// Pessimistic constraint (allows patch updates)
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
if (required == null)
|
||||
return false;
|
||||
|
||||
return actualVersion >= required &&
|
||||
actualVersion.Major == required.Major &&
|
||||
actualVersion.Minor == required.Minor;
|
||||
}
|
||||
|
||||
if (constraint.StartsWith("^"))
|
||||
{
|
||||
// Compatible with constraint (allows minor updates)
|
||||
var required = ParseVersion(constraint[1..]);
|
||||
if (required == null)
|
||||
return false;
|
||||
|
||||
return actualVersion >= required && actualVersion.Major == required.Major;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
var exact = ParseVersion(constraint);
|
||||
return exact != null && actualVersion == exact;
|
||||
}
|
||||
|
||||
private static Version? ParseVersion(string versionString)
|
||||
{
|
||||
versionString = versionString.Trim().TrimStart('v', 'V');
|
||||
|
||||
// Handle versions with only major.minor
|
||||
var parts = versionString.Split('.');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
versionString = $"{versionString}.0";
|
||||
}
|
||||
|
||||
return Version.TryParse(versionString, out var version) ? version : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Combines multiple plugin discovery sources into a single discovery interface.
|
||||
/// </summary>
|
||||
public sealed class CompositePluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly IReadOnlyList<IPluginDiscovery> _discoverers;
|
||||
private readonly ILogger<CompositePluginDiscovery> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new composite plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="discoverers">The list of discovery sources.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public CompositePluginDiscovery(
|
||||
IEnumerable<IPluginDiscovery> discoverers,
|
||||
ILogger<CompositePluginDiscovery> logger)
|
||||
{
|
||||
_discoverers = discoverers.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new composite plugin discovery with the default discoverers.
|
||||
/// </summary>
|
||||
/// <param name="fileSystemDiscovery">Filesystem discovery.</param>
|
||||
/// <param name="embeddedDiscovery">Embedded discovery.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public CompositePluginDiscovery(
|
||||
FileSystemPluginDiscovery fileSystemDiscovery,
|
||||
EmbeddedPluginDiscovery embeddedDiscovery,
|
||||
ILogger<CompositePluginDiscovery> logger)
|
||||
: this([fileSystemDiscovery, embeddedDiscovery], logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var allManifests = new List<PluginManifest>();
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var searchPathsList = searchPaths.ToList();
|
||||
|
||||
foreach (var discoverer in _discoverers)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var manifests = await discoverer.DiscoverAsync(searchPathsList, ct);
|
||||
|
||||
foreach (var manifest in manifests)
|
||||
{
|
||||
// Deduplicate by plugin ID
|
||||
if (seenIds.Add(manifest.Info.Id))
|
||||
{
|
||||
allManifests.Add(manifest);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Duplicate plugin ID {PluginId} found, using first occurrence",
|
||||
manifest.Info.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Discovery failed for {Discoverer}", discoverer.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discovered {Count} unique plugins from {SourceCount} sources",
|
||||
allManifests.Count, _discoverers.Count);
|
||||
|
||||
return allManifests;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
// Find the appropriate discoverer for this source type
|
||||
IPluginDiscovery? discoverer = source.Type switch
|
||||
{
|
||||
PluginSourceType.FileSystem => _discoverers.OfType<FileSystemPluginDiscovery>().FirstOrDefault(),
|
||||
PluginSourceType.Embedded => _discoverers.OfType<EmbeddedPluginDiscovery>().FirstOrDefault(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (discoverer == null)
|
||||
{
|
||||
throw new NotSupportedException($"No discoverer available for source type: {source.Type}");
|
||||
}
|
||||
|
||||
return await discoverer.DiscoverSingleAsync(source, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Attributes;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers plugins embedded in loaded assemblies.
|
||||
/// </summary>
|
||||
public sealed class EmbeddedPluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly ILogger<EmbeddedPluginDiscovery> _logger;
|
||||
private readonly IReadOnlyList<Assembly> _assemblies;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new embedded plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="assemblies">Optional list of assemblies to scan. If null, scans all loaded assemblies.</param>
|
||||
public EmbeddedPluginDiscovery(
|
||||
ILogger<EmbeddedPluginDiscovery> logger,
|
||||
IEnumerable<Assembly>? assemblies = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_assemblies = assemblies?.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// searchPaths are ignored for embedded discovery
|
||||
// We only scan the provided assemblies or the application assemblies
|
||||
var manifests = new List<PluginManifest>();
|
||||
|
||||
var assembliesToScan = _assemblies.Count > 0
|
||||
? _assemblies
|
||||
: GetApplicationAssemblies();
|
||||
|
||||
foreach (var assembly in assembliesToScan)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var pluginTypes = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && typeof(IPlugin).IsAssignableFrom(t))
|
||||
.ToList();
|
||||
|
||||
foreach (var pluginType in pluginTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = CreateManifestFromType(pluginType);
|
||||
if (manifest != null)
|
||||
{
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug(
|
||||
"Discovered embedded plugin {PluginId} in assembly {Assembly}",
|
||||
manifest.Info.Id, assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to create manifest for type {Type} in assembly {Assembly}",
|
||||
pluginType.FullName, assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load types from assembly {Assembly}",
|
||||
assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginManifest>>(manifests);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
if (source.Type != PluginSourceType.Embedded)
|
||||
throw new ArgumentException($"Unsupported source type: {source.Type}", nameof(source));
|
||||
|
||||
// Location should be the fully qualified type name
|
||||
var typeName = source.Location;
|
||||
|
||||
foreach (var assembly in GetApplicationAssemblies())
|
||||
{
|
||||
var type = assembly.GetType(typeName);
|
||||
if (type != null && typeof(IPlugin).IsAssignableFrom(type))
|
||||
{
|
||||
var manifest = CreateManifestFromType(type);
|
||||
if (manifest != null)
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Embedded plugin type not found: {typeName}");
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Assembly> GetApplicationAssemblies()
|
||||
{
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
if (entryAssembly == null)
|
||||
return AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
var referencedNames = entryAssembly.GetReferencedAssemblies();
|
||||
var assemblies = new List<Assembly> { entryAssembly };
|
||||
|
||||
foreach (var name in referencedNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
assemblies.Add(Assembly.Load(name));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip assemblies that can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
return assemblies;
|
||||
}
|
||||
|
||||
private static PluginManifest? CreateManifestFromType(Type pluginType)
|
||||
{
|
||||
var attribute = pluginType.GetCustomAttribute<PluginAttribute>();
|
||||
if (attribute == null)
|
||||
return null;
|
||||
|
||||
return new PluginManifest
|
||||
{
|
||||
Info = new PluginInfo(
|
||||
Id: attribute.Id,
|
||||
Name: attribute.Name ?? pluginType.Name,
|
||||
Version: attribute.Version ?? "1.0.0",
|
||||
Vendor: attribute.Vendor ?? "Unknown",
|
||||
Description: attribute.Description),
|
||||
EntryPoint = pluginType.FullName!,
|
||||
AssemblyPath = pluginType.Assembly.Location,
|
||||
Capabilities = [],
|
||||
Dependencies = [],
|
||||
Permissions = [],
|
||||
Tags = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers plugins from the filesystem by scanning for manifest files.
|
||||
/// </summary>
|
||||
public sealed class FileSystemPluginDiscovery : IPluginDiscovery
|
||||
{
|
||||
private readonly ILogger<FileSystemPluginDiscovery> _logger;
|
||||
private static readonly string[] ManifestFileNames = ["plugin.yaml", "plugin.yml", "plugin.json"];
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new filesystem plugin discovery instance.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public FileSystemPluginDiscovery(ILogger<FileSystemPluginDiscovery> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var manifests = new List<PluginManifest>();
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
if (!Directory.Exists(searchPath))
|
||||
{
|
||||
_logger.LogWarning("Plugin search path does not exist: {Path}", searchPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Searching for plugins in {Path}", searchPath);
|
||||
|
||||
// Look for plugin directories (contain plugin.yaml/plugin.json)
|
||||
foreach (var dir in Directory.EnumerateDirectories(searchPath))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var manifestPath = FindManifestFile(dir);
|
||||
if (manifestPath == null)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = await ParseManifestAsync(manifestPath, ct);
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug("Discovered plugin {PluginId} at {Path}", manifest.Info.Id, dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse manifest at {Path}", manifestPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if there's a manifest directly in the search path
|
||||
var directManifest = FindManifestFile(searchPath);
|
||||
if (directManifest != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = await ParseManifestAsync(directManifest, ct);
|
||||
manifests.Add(manifest);
|
||||
_logger.LogDebug("Discovered plugin {PluginId} at {Path}", manifest.Info.Id, searchPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse manifest at {Path}", directManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
if (source.Type != PluginSourceType.FileSystem)
|
||||
throw new ArgumentException($"Unsupported source type: {source.Type}", nameof(source));
|
||||
|
||||
var manifestPath = FindManifestFile(source.Location)
|
||||
?? throw new FileNotFoundException($"No plugin manifest found in {source.Location}");
|
||||
|
||||
return await ParseManifestAsync(manifestPath, ct);
|
||||
}
|
||||
|
||||
private static string? FindManifestFile(string directory)
|
||||
{
|
||||
foreach (var fileName in ManifestFileNames)
|
||||
{
|
||||
var path = Path.Combine(directory, fileName);
|
||||
if (File.Exists(path))
|
||||
return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<PluginManifest> ParseManifestAsync(string manifestPath, CancellationToken ct)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var extension = Path.GetExtension(manifestPath).ToLowerInvariant();
|
||||
|
||||
var manifest = extension switch
|
||||
{
|
||||
".yaml" or ".yml" => ParseYamlManifest(content),
|
||||
".json" => ParseJsonManifest(content),
|
||||
_ => throw new InvalidOperationException($"Unknown manifest format: {extension}")
|
||||
};
|
||||
|
||||
manifest.ManifestPath = manifestPath;
|
||||
|
||||
// Resolve assembly path relative to manifest location
|
||||
if (manifest.AssemblyPath != null && !Path.IsPathRooted(manifest.AssemblyPath))
|
||||
{
|
||||
var manifestDir = Path.GetDirectoryName(manifestPath)!;
|
||||
var absolutePath = Path.GetFullPath(Path.Combine(manifestDir, manifest.AssemblyPath));
|
||||
|
||||
// Create a new manifest with the resolved path
|
||||
manifest = new PluginManifest
|
||||
{
|
||||
Info = manifest.Info,
|
||||
EntryPoint = manifest.EntryPoint,
|
||||
AssemblyPath = absolutePath,
|
||||
MinPlatformVersion = manifest.MinPlatformVersion,
|
||||
MaxPlatformVersion = manifest.MaxPlatformVersion,
|
||||
Capabilities = manifest.Capabilities,
|
||||
ConfigSchema = manifest.ConfigSchema,
|
||||
DefaultConfig = manifest.DefaultConfig,
|
||||
Dependencies = manifest.Dependencies,
|
||||
Resources = manifest.Resources,
|
||||
Permissions = manifest.Permissions,
|
||||
Tags = manifest.Tags,
|
||||
Metadata = manifest.Metadata,
|
||||
ManifestPath = manifestPath
|
||||
};
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static PluginManifest ParseYamlManifest(string content)
|
||||
{
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
var dto = deserializer.Deserialize<ManifestDto>(content);
|
||||
return dto.ToManifest();
|
||||
}
|
||||
|
||||
private static PluginManifest ParseJsonManifest(string content)
|
||||
{
|
||||
return JsonSerializer.Deserialize<PluginManifest>(content, JsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to parse manifest JSON");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for YAML deserialization.
|
||||
/// </summary>
|
||||
private sealed class ManifestDto
|
||||
{
|
||||
public required InfoDto Info { get; set; }
|
||||
public required string EntryPoint { get; set; }
|
||||
public string? AssemblyPath { get; set; }
|
||||
public string? MinPlatformVersion { get; set; }
|
||||
public string? MaxPlatformVersion { get; set; }
|
||||
public List<CapabilityDto>? Capabilities { get; set; }
|
||||
public List<DependencyDto>? Dependencies { get; set; }
|
||||
public ResourcesDto? Resources { get; set; }
|
||||
public List<string>? Permissions { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public PluginManifest ToManifest()
|
||||
{
|
||||
return new PluginManifest
|
||||
{
|
||||
Info = Info.ToPluginInfo(),
|
||||
EntryPoint = EntryPoint,
|
||||
AssemblyPath = AssemblyPath,
|
||||
MinPlatformVersion = MinPlatformVersion,
|
||||
MaxPlatformVersion = MaxPlatformVersion,
|
||||
Capabilities = Capabilities?.Select(c => c.ToCapabilityDeclaration()).ToList() ?? [],
|
||||
Dependencies = Dependencies?.Select(d => d.ToPluginDependency()).ToList() ?? [],
|
||||
Resources = Resources?.ToResourceRequirements(),
|
||||
Permissions = Permissions ?? [],
|
||||
Tags = Tags ?? [],
|
||||
Metadata = Metadata
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class InfoDto
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Version { get; set; }
|
||||
public required string Vendor { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? License { get; set; }
|
||||
public string? Homepage { get; set; }
|
||||
public string? Repository { get; set; }
|
||||
|
||||
public Abstractions.PluginInfo ToPluginInfo()
|
||||
{
|
||||
return new Abstractions.PluginInfo(
|
||||
Id: Id,
|
||||
Name: Name,
|
||||
Version: Version,
|
||||
Vendor: Vendor,
|
||||
Description: Description,
|
||||
LicenseId: License,
|
||||
ProjectUrl: Homepage ?? Repository);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CapabilityDto
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public CapabilityDeclaration ToCapabilityDeclaration()
|
||||
{
|
||||
return new CapabilityDeclaration
|
||||
{
|
||||
Type = Type,
|
||||
Id = Id,
|
||||
DisplayName = DisplayName,
|
||||
Description = Description,
|
||||
Metadata = Metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DependencyDto
|
||||
{
|
||||
public required string PluginId { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public bool Optional { get; set; }
|
||||
|
||||
public PluginDependency ToPluginDependency()
|
||||
{
|
||||
return new PluginDependency(PluginId, Version, Optional);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ResourcesDto
|
||||
{
|
||||
public int MinMemoryMb { get; set; } = 64;
|
||||
public int MaxMemoryMb { get; set; } = 512;
|
||||
public string? CpuLimit { get; set; }
|
||||
public int DiskMb { get; set; } = 100;
|
||||
public bool NetworkRequired { get; set; }
|
||||
public bool GpuRequired { get; set; }
|
||||
|
||||
public ResourceRequirements ToResourceRequirements()
|
||||
{
|
||||
return new ResourceRequirements
|
||||
{
|
||||
MinMemoryMb = MinMemoryMb,
|
||||
MaxMemoryMb = MaxMemoryMb,
|
||||
CpuLimit = CpuLimit,
|
||||
DiskMb = DiskMb,
|
||||
NetworkRequired = NetworkRequired,
|
||||
GpuRequired = GpuRequired
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for discovering plugins from various sources.
|
||||
/// </summary>
|
||||
public interface IPluginDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Discover all plugins from search paths.
|
||||
/// </summary>
|
||||
/// <param name="searchPaths">Paths to search for plugins.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of discovered plugin manifests.</returns>
|
||||
Task<IReadOnlyList<PluginManifest>> DiscoverAsync(
|
||||
IEnumerable<string> searchPaths,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Discover a single plugin from a specific source.
|
||||
/// </summary>
|
||||
/// <param name="source">The plugin source.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The discovered plugin manifest.</returns>
|
||||
Task<PluginManifest> DiscoverSingleAsync(PluginSource source, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin discovery operation.
|
||||
/// </summary>
|
||||
/// <param name="Manifests">Discovered plugin manifests.</param>
|
||||
/// <param name="Errors">Errors encountered during discovery.</param>
|
||||
public sealed record PluginDiscoveryResult(
|
||||
IReadOnlyList<PluginManifest> Manifests,
|
||||
IReadOnlyList<PluginDiscoveryError> Errors)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful discovery result with no errors.
|
||||
/// </summary>
|
||||
public static PluginDiscoveryResult Success(IReadOnlyList<PluginManifest> manifests) =>
|
||||
new(manifests, []);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty discovery result.
|
||||
/// </summary>
|
||||
public static PluginDiscoveryResult Empty() =>
|
||||
new([], []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error encountered during plugin discovery.
|
||||
/// </summary>
|
||||
/// <param name="Location">Location where error occurred.</param>
|
||||
/// <param name="Message">Error message.</param>
|
||||
/// <param name="Exception">Optional exception.</param>
|
||||
public sealed record PluginDiscoveryError(
|
||||
string Location,
|
||||
string Message,
|
||||
Exception? Exception = null);
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Host.Context;
|
||||
using StellaOps.Plugin.Host.Dependencies;
|
||||
using StellaOps.Plugin.Host.Discovery;
|
||||
using StellaOps.Plugin.Host.Health;
|
||||
using StellaOps.Plugin.Host.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Loading;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering plugin host services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the plugin host and related services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginHost(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Bind options
|
||||
services.AddOptions<PluginHostOptions>()
|
||||
.Bind(configuration.GetSection(PluginHostOptions.SectionName));
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Discovery services
|
||||
services.AddSingleton<FileSystemPluginDiscovery>();
|
||||
services.AddSingleton<EmbeddedPluginDiscovery>();
|
||||
services.AddSingleton<IPluginDiscovery, CompositePluginDiscovery>();
|
||||
|
||||
// Loading services
|
||||
services.AddSingleton<IHostPluginLoader, AssemblyPluginLoader>();
|
||||
|
||||
// Lifecycle services
|
||||
services.AddSingleton<IPluginLifecycleManager, PluginLifecycleManager>();
|
||||
|
||||
// Health monitoring
|
||||
services.AddSingleton<IPluginHealthMonitor, PluginHealthMonitor>();
|
||||
|
||||
// Dependency resolution
|
||||
services.AddSingleton<IPluginDependencyResolver, PluginDependencyResolver>();
|
||||
|
||||
// Context factory
|
||||
services.AddSingleton<PluginContextFactory>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var options = sp.GetRequiredService<IOptions<PluginHostOptions>>().Value;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
return new PluginContextFactory(
|
||||
loggerFactory,
|
||||
options,
|
||||
sp,
|
||||
timeProvider);
|
||||
});
|
||||
|
||||
// Plugin host
|
||||
services.AddSingleton<IPluginHost, PluginHost>();
|
||||
|
||||
// Hosted service to start/stop plugin host
|
||||
services.AddHostedService<PluginHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin host with custom options configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginHost(
|
||||
this IServiceCollection services,
|
||||
Action<PluginHostOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Discovery services
|
||||
services.AddSingleton<FileSystemPluginDiscovery>();
|
||||
services.AddSingleton<EmbeddedPluginDiscovery>();
|
||||
services.AddSingleton<IPluginDiscovery, CompositePluginDiscovery>();
|
||||
|
||||
// Loading services
|
||||
services.AddSingleton<IHostPluginLoader, AssemblyPluginLoader>();
|
||||
|
||||
// Lifecycle services
|
||||
services.AddSingleton<IPluginLifecycleManager, PluginLifecycleManager>();
|
||||
|
||||
// Health monitoring
|
||||
services.AddSingleton<IPluginHealthMonitor, PluginHealthMonitor>();
|
||||
|
||||
// Dependency resolution
|
||||
services.AddSingleton<IPluginDependencyResolver, PluginDependencyResolver>();
|
||||
|
||||
// Context factory
|
||||
services.AddSingleton<PluginContextFactory>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var options = sp.GetRequiredService<IOptions<PluginHostOptions>>().Value;
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
|
||||
return new PluginContextFactory(
|
||||
loggerFactory,
|
||||
options,
|
||||
sp,
|
||||
timeProvider);
|
||||
});
|
||||
|
||||
// Plugin host
|
||||
services.AddSingleton<IPluginHost, PluginHost>();
|
||||
|
||||
// Hosted service to start/stop plugin host
|
||||
services.AddHostedService<PluginHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the plugin host lifecycle.
|
||||
/// </summary>
|
||||
public sealed class PluginHostedService : IHostedService
|
||||
{
|
||||
private readonly IPluginHost _pluginHost;
|
||||
private readonly ILogger<PluginHostedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin hosted service.
|
||||
/// </summary>
|
||||
/// <param name="pluginHost">The plugin host.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public PluginHostedService(IPluginHost pluginHost, ILogger<PluginHostedService> logger)
|
||||
{
|
||||
_pluginHost = pluginHost;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Starting plugin host via hosted service...");
|
||||
await _pluginHost.StartAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping plugin host via hosted service...");
|
||||
await _pluginHost.StopAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Monitors the health of loaded plugins.
|
||||
/// </summary>
|
||||
public interface IPluginHealthMonitor : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Start the health monitoring loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop the health monitoring loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Register a plugin for health monitoring.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The loaded plugin.</param>
|
||||
void RegisterPlugin(LoadedPlugin plugin);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin from health monitoring.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
void UnregisterPlugin(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Perform an immediate health check on a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The health check result.</returns>
|
||||
Task<HealthCheckResult> CheckHealthAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current health status of a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The health status, or null if not registered.</returns>
|
||||
HealthStatus? GetHealthStatus(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get all plugin health statuses.
|
||||
/// </summary>
|
||||
/// <returns>Dictionary of plugin ID to health status.</returns>
|
||||
IReadOnlyDictionary<string, HealthStatus> GetAllHealthStatuses();
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin's health status changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginHealthChangedEventArgs>? HealthChanged;
|
||||
}
|
||||
253
src/Plugin/StellaOps.Plugin.Host/Health/PluginHealthMonitor.cs
Normal file
253
src/Plugin/StellaOps.Plugin.Host/Health/PluginHealthMonitor.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Monitors plugin health with periodic checks and status change notifications.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthMonitor : IPluginHealthMonitor
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginHealthState> _healthStates = new();
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly ILogger<PluginHealthMonitor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private Task? _monitorTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginHealthChangedEventArgs>? HealthChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin health monitor.
|
||||
/// </summary>
|
||||
/// <param name="options">Plugin host options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
public PluginHealthMonitor(
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<PluginHealthMonitor> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
|
||||
_logger.LogInformation("Plugin health monitor started with interval {Interval}",
|
||||
_options.HealthCheckInterval);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cts != null)
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
}
|
||||
|
||||
if (_monitorTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _monitorTask.WaitAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Plugin health monitor stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterPlugin(LoadedPlugin plugin)
|
||||
{
|
||||
var state = new PluginHealthState
|
||||
{
|
||||
Plugin = plugin,
|
||||
LastCheck = _timeProvider.GetUtcNow(),
|
||||
Status = HealthStatus.Healthy,
|
||||
ConsecutiveFailures = 0
|
||||
};
|
||||
|
||||
_healthStates[plugin.Info.Id] = state;
|
||||
_logger.LogDebug("Registered plugin {PluginId} for health monitoring", plugin.Info.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnregisterPlugin(string pluginId)
|
||||
{
|
||||
if (_healthStates.TryRemove(pluginId, out _))
|
||||
{
|
||||
_logger.LogDebug("Unregistered plugin {PluginId} from health monitoring", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_healthStates.TryGetValue(pluginId, out var state))
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Plugin not registered for health monitoring");
|
||||
}
|
||||
|
||||
return await PerformHealthCheckAsync(state, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HealthStatus? GetHealthStatus(string pluginId)
|
||||
{
|
||||
return _healthStates.TryGetValue(pluginId, out var state) ? state.Status : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, HealthStatus> GetAllHealthStatuses()
|
||||
{
|
||||
return _healthStates.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.Status);
|
||||
}
|
||||
|
||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var timer = new PeriodicTimer(_options.HealthCheckInterval);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await timer.WaitForNextTickAsync(ct);
|
||||
|
||||
// Check all registered plugins
|
||||
foreach (var kvp in _healthStates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var state = kvp.Value;
|
||||
var timeSinceLastCheck = _timeProvider.GetUtcNow() - state.LastCheck;
|
||||
|
||||
if (timeSinceLastCheck >= _options.HealthCheckInterval)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PerformHealthCheckAsync(state, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed for plugin {PluginId}", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health monitor loop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HealthCheckResult> PerformHealthCheckAsync(PluginHealthState state, CancellationToken ct)
|
||||
{
|
||||
var plugin = state.Plugin;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_options.HealthCheckTimeout);
|
||||
|
||||
var result = await plugin.Instance.HealthCheckAsync(timeoutCts.Token);
|
||||
stopwatch.Stop();
|
||||
|
||||
result = result with { Duration = stopwatch.Elapsed };
|
||||
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Timeout
|
||||
var result = HealthCheckResult.Unhealthy("Health check timed out")
|
||||
.WithDuration(stopwatch.Elapsed);
|
||||
|
||||
state.ConsecutiveFailures++;
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
var result = HealthCheckResult.Unhealthy(ex)
|
||||
.WithDuration(stopwatch.Elapsed);
|
||||
|
||||
state.ConsecutiveFailures++;
|
||||
UpdateHealthState(state, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHealthState(PluginHealthState state, HealthCheckResult result)
|
||||
{
|
||||
var oldStatus = state.Status;
|
||||
var newStatus = result.Status;
|
||||
|
||||
state.Status = newStatus;
|
||||
state.LastCheck = _timeProvider.GetUtcNow();
|
||||
state.LastResult = result;
|
||||
|
||||
if (newStatus == HealthStatus.Healthy)
|
||||
{
|
||||
state.ConsecutiveFailures = 0;
|
||||
}
|
||||
|
||||
// Raise event if status changed
|
||||
if (oldStatus != newStatus)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Plugin {PluginId} health changed from {OldStatus} to {NewStatus}",
|
||||
state.Plugin.Info.Id, oldStatus, newStatus);
|
||||
|
||||
HealthChanged?.Invoke(this, new PluginHealthChangedEventArgs
|
||||
{
|
||||
PluginId = state.Plugin.Info.Id,
|
||||
OldStatus = oldStatus,
|
||||
NewStatus = newStatus,
|
||||
CheckResult = result
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None);
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal class to track plugin health state.
|
||||
/// </summary>
|
||||
private sealed class PluginHealthState
|
||||
{
|
||||
public required LoadedPlugin Plugin { get; init; }
|
||||
public DateTimeOffset LastCheck { get; set; }
|
||||
public HealthStatus Status { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public HealthCheckResult? LastResult { get; set; }
|
||||
}
|
||||
}
|
||||
174
src/Plugin/StellaOps.Plugin.Host/IPluginHost.cs
Normal file
174
src/Plugin/StellaOps.Plugin.Host/IPluginHost.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Central coordinator for plugin lifecycle management.
|
||||
/// Handles discovery, loading, initialization, and shutdown of plugins.
|
||||
/// </summary>
|
||||
public interface IPluginHost : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// All currently loaded plugins.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, LoadedPlugin> Plugins { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Discover and load all plugins from configured sources.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gracefully stop all plugins and release resources.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Load a specific plugin from a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The plugin source.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The loaded plugin.</returns>
|
||||
Task<LoadedPlugin> LoadPluginAsync(PluginSource source, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Unload a specific plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to unload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnloadPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reload a plugin (unload then load).
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to reload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The reloaded plugin.</returns>
|
||||
Task<LoadedPlugin> ReloadPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins that implement a specific capability interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <returns>Enumeration of plugins with the capability.</returns>
|
||||
IEnumerable<T> GetPluginsWithCapability<T>() where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific plugin by ID.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The plugin, or null if not found.</returns>
|
||||
LoadedPlugin? GetPlugin(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get a plugin capability instance.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The capability interface type.</typeparam>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The capability, or null if not found or not provided.</returns>
|
||||
T? GetCapability<T>(string pluginId) where T : class;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin state changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginStateChangedEventArgs>? PluginStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin health status changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginHealthChangedEventArgs>? PluginHealthChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a source from which a plugin can be loaded.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of source.</param>
|
||||
/// <param name="Location">The source location (path, URL, etc.).</param>
|
||||
/// <param name="Metadata">Optional metadata about the source.</param>
|
||||
public sealed record PluginSource(
|
||||
PluginSourceType Type,
|
||||
string Location,
|
||||
IReadOnlyDictionary<string, object>? Metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Types of plugin sources.
|
||||
/// </summary>
|
||||
public enum PluginSourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin loaded from filesystem.
|
||||
/// </summary>
|
||||
FileSystem,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin embedded in the application.
|
||||
/// </summary>
|
||||
Embedded,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin loaded from a remote URL.
|
||||
/// </summary>
|
||||
Remote,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin definition stored in database.
|
||||
/// </summary>
|
||||
Database
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for plugin state changes.
|
||||
/// </summary>
|
||||
public sealed class PluginStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the plugin whose state changed.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState OldState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState NewState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the state change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for plugin health changes.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the plugin whose health changed.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous health status.
|
||||
/// </summary>
|
||||
public required HealthStatus OldStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New health status.
|
||||
/// </summary>
|
||||
public required HealthStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The health check result that triggered the change.
|
||||
/// </summary>
|
||||
public HealthCheckResult? CheckResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Manages plugin lifecycle state transitions.
|
||||
/// </summary>
|
||||
public interface IPluginLifecycleManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a plugin for lifecycle management.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="initialState">Initial state.</param>
|
||||
void Register(string pluginId, PluginLifecycleState initialState = PluginLifecycleState.Discovered);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin from lifecycle management.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
void Unregister(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current state of a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The current state, or null if not registered.</returns>
|
||||
PluginLifecycleState? GetState(string pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to transition a plugin to a new state.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="targetState">The target state.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if transition succeeded.</returns>
|
||||
Task<bool> TransitionAsync(string pluginId, PluginLifecycleState targetState, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a transition is valid.
|
||||
/// </summary>
|
||||
/// <param name="fromState">Current state.</param>
|
||||
/// <param name="toState">Target state.</param>
|
||||
/// <returns>True if the transition is allowed.</returns>
|
||||
bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a plugin state changes.
|
||||
/// </summary>
|
||||
event EventHandler<PluginStateTransitionEventArgs>? StateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for state transitions.
|
||||
/// </summary>
|
||||
public sealed class PluginStateTransitionEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin ID.
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The previous state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState FromState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState ToState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the transition occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset TransitionedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the transition.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Manages plugin lifecycle state transitions with validation and event notification.
|
||||
/// </summary>
|
||||
public sealed class PluginLifecycleManager : IPluginLifecycleManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginLifecycleInfo> _pluginStates = new();
|
||||
private readonly ILogger<PluginLifecycleManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginStateTransitionEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin lifecycle manager.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Time provider.</param>
|
||||
public PluginLifecycleManager(
|
||||
ILogger<PluginLifecycleManager> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Register(string pluginId, PluginLifecycleState initialState = PluginLifecycleState.Discovered)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var info = new PluginLifecycleInfo(pluginId, initialState, now);
|
||||
|
||||
if (!_pluginStates.TryAdd(pluginId, info))
|
||||
{
|
||||
_logger.LogWarning("Plugin {PluginId} is already registered", pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Registered plugin {PluginId} with initial state {State}", pluginId, initialState);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unregister(string pluginId)
|
||||
{
|
||||
if (_pluginStates.TryRemove(pluginId, out _))
|
||||
{
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState? GetState(string pluginId)
|
||||
{
|
||||
return _pluginStates.TryGetValue(pluginId, out var info) ? info.CurrentState : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TransitionAsync(string pluginId, PluginLifecycleState targetState, CancellationToken ct)
|
||||
{
|
||||
if (!_pluginStates.TryGetValue(pluginId, out var info))
|
||||
{
|
||||
_logger.LogWarning("Cannot transition unregistered plugin {PluginId}", pluginId);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
lock (info.Lock)
|
||||
{
|
||||
var currentState = info.CurrentState;
|
||||
|
||||
if (currentState == targetState)
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} is already in state {State}", pluginId, targetState);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
if (!IsValidTransition(currentState, targetState))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid state transition for plugin {PluginId}: {FromState} -> {ToState}",
|
||||
pluginId, currentState, targetState);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var transition = new PluginStateTransition(currentState, targetState, now);
|
||||
|
||||
info.CurrentState = targetState;
|
||||
info.LastTransitionedAt = now;
|
||||
info.TransitionHistory.Add(transition);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Plugin {PluginId} transitioned from {FromState} to {ToState}",
|
||||
pluginId, currentState, targetState);
|
||||
|
||||
// Raise event
|
||||
StateChanged?.Invoke(this, new PluginStateTransitionEventArgs
|
||||
{
|
||||
PluginId = pluginId,
|
||||
FromState = currentState,
|
||||
ToState = targetState,
|
||||
TransitionedAt = now
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState)
|
||||
{
|
||||
return PluginStateMachine.IsValidTransition(fromState, toState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transition history for a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>The transition history, or empty if not found.</returns>
|
||||
public IReadOnlyList<PluginStateTransition> GetTransitionHistory(string pluginId)
|
||||
{
|
||||
return _pluginStates.TryGetValue(pluginId, out var info)
|
||||
? info.TransitionHistory.ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered plugin IDs.
|
||||
/// </summary>
|
||||
/// <returns>Set of plugin IDs.</returns>
|
||||
public IReadOnlySet<string> GetRegisteredPlugins()
|
||||
{
|
||||
return _pluginStates.Keys.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets plugins in a specific state.
|
||||
/// </summary>
|
||||
/// <param name="state">The state to filter by.</param>
|
||||
/// <returns>List of plugin IDs in that state.</returns>
|
||||
public IReadOnlyList<string> GetPluginsInState(PluginLifecycleState state)
|
||||
{
|
||||
return _pluginStates
|
||||
.Where(kvp => kvp.Value.CurrentState == state)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal class to track plugin lifecycle state.
|
||||
/// </summary>
|
||||
private sealed class PluginLifecycleInfo
|
||||
{
|
||||
public string PluginId { get; }
|
||||
public PluginLifecycleState CurrentState { get; set; }
|
||||
public DateTimeOffset RegisteredAt { get; }
|
||||
public DateTimeOffset LastTransitionedAt { get; set; }
|
||||
public List<PluginStateTransition> TransitionHistory { get; } = [];
|
||||
public object Lock { get; } = new();
|
||||
|
||||
public PluginLifecycleInfo(string pluginId, PluginLifecycleState initialState, DateTimeOffset registeredAt)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
CurrentState = initialState;
|
||||
RegisteredAt = registeredAt;
|
||||
LastTransitionedAt = registeredAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/Plugin/StellaOps.Plugin.Host/Lifecycle/PluginStateMachine.cs
Normal file
150
src/Plugin/StellaOps.Plugin.Host/Lifecycle/PluginStateMachine.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the valid state transitions for plugin lifecycle.
|
||||
/// </summary>
|
||||
public static class PluginStateMachine
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines all valid state transitions.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<PluginLifecycleState, HashSet<PluginLifecycleState>> ValidTransitions = new()
|
||||
{
|
||||
// Discovered can transition to Loading or Failed
|
||||
[PluginLifecycleState.Discovered] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Loading can transition to Initializing or Failed
|
||||
[PluginLifecycleState.Loading] =
|
||||
[
|
||||
PluginLifecycleState.Initializing,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Initializing can transition to Active, Degraded, or Failed
|
||||
[PluginLifecycleState.Initializing] =
|
||||
[
|
||||
PluginLifecycleState.Active,
|
||||
PluginLifecycleState.Degraded,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Active can transition to Degraded, Stopping, or Failed
|
||||
[PluginLifecycleState.Active] =
|
||||
[
|
||||
PluginLifecycleState.Degraded,
|
||||
PluginLifecycleState.Stopping,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Degraded can transition to Active, Stopping, or Failed
|
||||
[PluginLifecycleState.Degraded] =
|
||||
[
|
||||
PluginLifecycleState.Active,
|
||||
PluginLifecycleState.Stopping,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Stopping can transition to Stopped, Unloading, or Failed
|
||||
[PluginLifecycleState.Stopping] =
|
||||
[
|
||||
PluginLifecycleState.Stopped,
|
||||
PluginLifecycleState.Unloading,
|
||||
PluginLifecycleState.Failed
|
||||
],
|
||||
|
||||
// Stopped can transition to Loading (reload) or Unloading
|
||||
[PluginLifecycleState.Stopped] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Unloading
|
||||
],
|
||||
|
||||
// Failed can transition to Loading (retry) or Unloading
|
||||
[PluginLifecycleState.Failed] =
|
||||
[
|
||||
PluginLifecycleState.Loading,
|
||||
PluginLifecycleState.Unloading
|
||||
],
|
||||
|
||||
// Unloading is terminal - no transitions allowed
|
||||
[PluginLifecycleState.Unloading] = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Check if a state transition is valid.
|
||||
/// </summary>
|
||||
/// <param name="fromState">Current state.</param>
|
||||
/// <param name="toState">Target state.</param>
|
||||
/// <returns>True if the transition is allowed.</returns>
|
||||
public static bool IsValidTransition(PluginLifecycleState fromState, PluginLifecycleState toState)
|
||||
{
|
||||
return ValidTransitions.TryGetValue(fromState, out var validTargets)
|
||||
&& validTargets.Contains(toState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all valid target states from a given state.
|
||||
/// </summary>
|
||||
/// <param name="fromState">The current state.</param>
|
||||
/// <returns>Set of valid target states.</returns>
|
||||
public static IReadOnlySet<PluginLifecycleState> GetValidTargets(PluginLifecycleState fromState)
|
||||
{
|
||||
return ValidTransitions.TryGetValue(fromState, out var validTargets)
|
||||
? validTargets
|
||||
: new HashSet<PluginLifecycleState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is operational (can handle requests).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if operational.</returns>
|
||||
public static bool IsOperational(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Active || state == PluginLifecycleState.Degraded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is terminal (plugin is done).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if terminal.</returns>
|
||||
public static bool IsTerminal(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Stopped
|
||||
|| state == PluginLifecycleState.Failed
|
||||
|| state == PluginLifecycleState.Unloading;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a state is transitional (in progress).
|
||||
/// </summary>
|
||||
/// <param name="state">The state to check.</param>
|
||||
/// <returns>True if transitional.</returns>
|
||||
public static bool IsTransitional(PluginLifecycleState state)
|
||||
{
|
||||
return state == PluginLifecycleState.Loading
|
||||
|| state == PluginLifecycleState.Initializing
|
||||
|| state == PluginLifecycleState.Stopping
|
||||
|| state == PluginLifecycleState.Unloading;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a state transition record.
|
||||
/// </summary>
|
||||
/// <param name="FromState">Previous state.</param>
|
||||
/// <param name="ToState">New state.</param>
|
||||
/// <param name="TransitionedAt">When the transition occurred.</param>
|
||||
/// <param name="Reason">Optional reason for the transition.</param>
|
||||
public sealed record PluginStateTransition(
|
||||
PluginLifecycleState FromState,
|
||||
PluginLifecycleState ToState,
|
||||
DateTimeOffset TransitionedAt,
|
||||
string? Reason = null);
|
||||
213
src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs
Normal file
213
src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins from assemblies with optional isolation using AssemblyLoadContext.
|
||||
/// </summary>
|
||||
public sealed class AssemblyPluginLoader : IHostPluginLoader
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginLoadContextReference> _loadContexts = new();
|
||||
private readonly ILogger<AssemblyPluginLoader> _logger;
|
||||
private readonly PluginHostOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new assembly plugin loader.
|
||||
/// </summary>
|
||||
/// <param name="options">Plugin host options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public AssemblyPluginLoader(
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<AssemblyPluginLoader> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginAssemblyLoadResult> LoadAsync(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var assemblyPath = ResolveAssemblyPath(manifest);
|
||||
var pluginId = manifest.Info.Id;
|
||||
|
||||
_logger.LogDebug("Loading plugin assembly from {Path}", assemblyPath);
|
||||
|
||||
// Determine if we should isolate this plugin
|
||||
var shouldIsolate = _options.EnableAssemblyIsolation && trustLevel != PluginTrustLevel.BuiltIn;
|
||||
|
||||
PluginAssemblyLoadContext? loadContext = null;
|
||||
Assembly assembly;
|
||||
|
||||
if (shouldIsolate)
|
||||
{
|
||||
// Create isolated load context
|
||||
loadContext = new PluginAssemblyLoadContext(
|
||||
pluginId,
|
||||
assemblyPath,
|
||||
isCollectible: true);
|
||||
|
||||
_loadContexts[pluginId] = new PluginLoadContextReference(loadContext, pluginId);
|
||||
|
||||
try
|
||||
{
|
||||
assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Cleanup on failure
|
||||
_loadContexts.TryRemove(pluginId, out _);
|
||||
loadContext.Unload();
|
||||
throw new PluginLoadException(pluginId, $"Failed to load assembly: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Load into default context (for built-in plugins)
|
||||
assembly = Assembly.LoadFrom(assemblyPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Find the entry point type
|
||||
var entryPointType = assembly.GetType(manifest.EntryPoint)
|
||||
?? throw new PluginLoadException(pluginId, $"Entry point type '{manifest.EntryPoint}' not found");
|
||||
|
||||
// Verify it implements IPlugin
|
||||
if (!typeof(IPlugin).IsAssignableFrom(entryPointType))
|
||||
throw new PluginLoadException(pluginId, $"Entry point type '{manifest.EntryPoint}' does not implement IPlugin");
|
||||
|
||||
// Create instance
|
||||
var instance = Activator.CreateInstance(entryPointType) as IPlugin
|
||||
?? throw new PluginLoadException(pluginId, $"Failed to create instance of '{manifest.EntryPoint}'");
|
||||
|
||||
_logger.LogDebug("Loaded plugin {PluginId} (isolated: {Isolated})", pluginId, shouldIsolate);
|
||||
|
||||
return Task.FromResult(new PluginAssemblyLoadResult(instance, assembly, loadContext));
|
||||
}
|
||||
catch (Exception ex) when (ex is not PluginLoadException)
|
||||
{
|
||||
// Cleanup on failure
|
||||
if (loadContext != null)
|
||||
{
|
||||
_loadContexts.TryRemove(pluginId, out _);
|
||||
loadContext.Unload();
|
||||
}
|
||||
throw new PluginLoadException(pluginId, $"Failed to instantiate plugin: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginAssemblyLoadResult> LoadAsync<T>(
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct) where T : class, IPlugin, new()
|
||||
{
|
||||
var instance = new T();
|
||||
var assembly = typeof(T).Assembly;
|
||||
|
||||
// Embedded plugins are loaded in the default context
|
||||
return Task.FromResult(new PluginAssemblyLoadResult(instance, assembly, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnloadAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_loadContexts.TryRemove(pluginId, out var contextRef))
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} was not isolated, no unload needed", pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
contextRef.Unload();
|
||||
|
||||
// Wait for GC to collect the assemblies
|
||||
const int maxAttempts = 10;
|
||||
const int delayMs = 100;
|
||||
|
||||
for (int i = 0; i < maxAttempts && !contextRef.IsCollected; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
if (!contextRef.IsCollected)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Plugin {PluginId} load context still alive after unload - possible memory leak",
|
||||
pluginId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Plugin {PluginId} unloaded and collected", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLoaded(string pluginId)
|
||||
{
|
||||
return _loadContexts.ContainsKey(pluginId);
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyPath(PluginManifest manifest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(manifest.AssemblyPath))
|
||||
{
|
||||
throw new PluginLoadException(
|
||||
manifest.Info.Id,
|
||||
"Assembly path not specified in manifest");
|
||||
}
|
||||
|
||||
if (!File.Exists(manifest.AssemblyPath))
|
||||
{
|
||||
throw new PluginLoadException(
|
||||
manifest.Info.Id,
|
||||
$"Assembly not found: {manifest.AssemblyPath}");
|
||||
}
|
||||
|
||||
return manifest.AssemblyPath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when plugin loading fails.
|
||||
/// </summary>
|
||||
public class PluginLoadException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin ID that failed to load.
|
||||
/// </summary>
|
||||
public string PluginId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin load exception.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
public PluginLoadException(string pluginId, string message)
|
||||
: base($"Failed to load plugin '{pluginId}': {message}")
|
||||
{
|
||||
PluginId = pluginId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin load exception with inner exception.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="inner">The inner exception.</param>
|
||||
public PluginLoadException(string pluginId, string message, Exception inner)
|
||||
: base($"Failed to load plugin '{pluginId}': {message}", inner)
|
||||
{
|
||||
PluginId = pluginId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for loading plugins from assemblies with isolation support.
|
||||
/// </summary>
|
||||
public interface IHostPluginLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a plugin from a manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The plugin manifest.</param>
|
||||
/// <param name="trustLevel">Trust level to apply to the plugin.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result with the plugin instance.</returns>
|
||||
Task<PluginAssemblyLoadResult> LoadAsync(
|
||||
PluginManifest manifest,
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Load a plugin from a type (for embedded plugins).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The plugin type.</typeparam>
|
||||
/// <param name="trustLevel">Trust level to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The load result.</returns>
|
||||
Task<PluginAssemblyLoadResult> LoadAsync<T>(
|
||||
PluginTrustLevel trustLevel,
|
||||
CancellationToken ct) where T : class, IPlugin, new();
|
||||
|
||||
/// <summary>
|
||||
/// Unload a plugin and release its resources.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">ID of the plugin to unload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task UnloadAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a plugin is currently loaded.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
/// <returns>True if loaded.</returns>
|
||||
bool IsLoaded(string pluginId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin assembly load operation.
|
||||
/// </summary>
|
||||
/// <param name="Instance">The loaded plugin instance.</param>
|
||||
/// <param name="Assembly">The loaded assembly.</param>
|
||||
/// <param name="LoadContext">The assembly load context (if isolated).</param>
|
||||
public sealed record PluginAssemblyLoadResult(
|
||||
IPlugin Instance,
|
||||
Assembly Assembly,
|
||||
PluginAssemblyLoadContext? LoadContext);
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace StellaOps.Plugin.Host.Loading;
|
||||
|
||||
/// <summary>
|
||||
/// Custom AssemblyLoadContext for loading plugins in isolation.
|
||||
/// Supports collectible assemblies for plugin unloading.
|
||||
/// </summary>
|
||||
public sealed class PluginAssemblyLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
private readonly WeakReference _weakReference;
|
||||
private readonly string _pluginPath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the load context is still alive (not collected).
|
||||
/// </summary>
|
||||
public bool IsAlive => _weakReference.IsAlive;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin path this context was created for.
|
||||
/// </summary>
|
||||
public string PluginPath => _pluginPath;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin assembly load context.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the context (typically plugin ID).</param>
|
||||
/// <param name="pluginPath">Path to the plugin assembly.</param>
|
||||
/// <param name="isCollectible">Whether the context supports unloading.</param>
|
||||
public PluginAssemblyLoadContext(string name, string pluginPath, bool isCollectible = true)
|
||||
: base(name, isCollectible)
|
||||
{
|
||||
_pluginPath = pluginPath;
|
||||
_resolver = new AssemblyDependencyResolver(pluginPath);
|
||||
_weakReference = new WeakReference(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
// Try to resolve from the plugin's dependencies
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
if (assemblyPath != null)
|
||||
{
|
||||
return LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
// Fall back to default context (shared framework assemblies)
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
if (libraryPath != null)
|
||||
{
|
||||
return LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a plugin assembly load context that allows checking if it's been collected.
|
||||
/// </summary>
|
||||
public sealed class PluginLoadContextReference
|
||||
{
|
||||
private readonly WeakReference<PluginAssemblyLoadContext> _contextRef;
|
||||
private readonly string _pluginId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin ID.
|
||||
/// </summary>
|
||||
public string PluginId => _pluginId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the load context has been collected.
|
||||
/// </summary>
|
||||
public bool IsCollected => !_contextRef.TryGetTarget(out _);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new load context reference.
|
||||
/// </summary>
|
||||
/// <param name="context">The load context.</param>
|
||||
/// <param name="pluginId">The plugin ID.</param>
|
||||
public PluginLoadContextReference(PluginAssemblyLoadContext context, string pluginId)
|
||||
{
|
||||
_contextRef = new WeakReference<PluginAssemblyLoadContext>(context);
|
||||
_pluginId = pluginId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the load context if it's still alive.
|
||||
/// </summary>
|
||||
/// <param name="context">The load context if alive.</param>
|
||||
/// <returns>True if the context is still alive.</returns>
|
||||
public bool TryGetContext(out PluginAssemblyLoadContext? context)
|
||||
{
|
||||
return _contextRef.TryGetTarget(out context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the unload of the load context.
|
||||
/// </summary>
|
||||
public void Unload()
|
||||
{
|
||||
if (_contextRef.TryGetTarget(out var context))
|
||||
{
|
||||
context.Unload();
|
||||
}
|
||||
}
|
||||
}
|
||||
418
src/Plugin/StellaOps.Plugin.Host/PluginHost.cs
Normal file
418
src/Plugin/StellaOps.Plugin.Host/PluginHost.cs
Normal file
@@ -0,0 +1,418 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Context;
|
||||
using StellaOps.Plugin.Host.Dependencies;
|
||||
using StellaOps.Plugin.Host.Discovery;
|
||||
using StellaOps.Plugin.Host.Health;
|
||||
using StellaOps.Plugin.Host.Lifecycle;
|
||||
using StellaOps.Plugin.Host.Loading;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Central coordinator for plugin lifecycle management.
|
||||
/// Handles discovery, loading, initialization, and shutdown of plugins.
|
||||
/// </summary>
|
||||
public sealed class PluginHost : IPluginHost
|
||||
{
|
||||
private readonly IPluginDiscovery _discovery;
|
||||
private readonly IHostPluginLoader _loader;
|
||||
private readonly IPluginLifecycleManager _lifecycle;
|
||||
private readonly IPluginHealthMonitor _healthMonitor;
|
||||
private readonly IPluginDependencyResolver _dependencyResolver;
|
||||
private readonly PluginContextFactory _contextFactory;
|
||||
private readonly PluginHostOptions _options;
|
||||
private readonly ILogger<PluginHost> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private readonly ConcurrentDictionary<string, LoadedPlugin> _plugins = new();
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
private CancellationTokenSource? _shutdownCts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, LoadedPlugin> Plugins => _plugins;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginStateChangedEventArgs>? PluginStateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<PluginHealthChangedEventArgs>? PluginHealthChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin host.
|
||||
/// </summary>
|
||||
public PluginHost(
|
||||
IPluginDiscovery discovery,
|
||||
IHostPluginLoader loader,
|
||||
IPluginLifecycleManager lifecycle,
|
||||
IPluginHealthMonitor healthMonitor,
|
||||
IPluginDependencyResolver dependencyResolver,
|
||||
PluginContextFactory contextFactory,
|
||||
IOptions<PluginHostOptions> options,
|
||||
ILogger<PluginHost> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_discovery = discovery;
|
||||
_loader = loader;
|
||||
_lifecycle = lifecycle;
|
||||
_healthMonitor = healthMonitor;
|
||||
_dependencyResolver = dependencyResolver;
|
||||
_contextFactory = contextFactory;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
|
||||
// Subscribe to health changes
|
||||
_healthMonitor.HealthChanged += OnPluginHealthChanged;
|
||||
_lifecycle.StateChanged += OnPluginStateTransition;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
_logger.LogInformation("Starting plugin host...");
|
||||
|
||||
// 1. Discover plugins
|
||||
var discovered = await _discovery.DiscoverAsync(_options.PluginPaths, ct);
|
||||
_logger.LogInformation("Discovered {Count} plugins", discovered.Count);
|
||||
|
||||
// 2. Validate dependency graph
|
||||
var cycles = _dependencyResolver.ValidateDependencyGraph(discovered);
|
||||
if (cycles.Count > 0)
|
||||
{
|
||||
var cycleDescriptions = string.Join("; ", cycles.Select(c => c.Description));
|
||||
_logger.LogError("Circular dependencies detected: {Cycles}", cycleDescriptions);
|
||||
|
||||
if (_options.FailOnPluginLoadError)
|
||||
throw new InvalidOperationException($"Circular dependencies detected: {cycleDescriptions}");
|
||||
}
|
||||
|
||||
// 3. Resolve dependencies and determine load order
|
||||
var loadOrder = _dependencyResolver.ResolveLoadOrder(discovered);
|
||||
|
||||
// 4. Load plugins in dependency order
|
||||
foreach (var manifest in loadOrder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load plugin {PluginId}", manifest.Info.Id);
|
||||
|
||||
if (_options.FailOnPluginLoadError)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start health monitoring
|
||||
await _healthMonitor.StartAsync(_shutdownCts.Token);
|
||||
|
||||
var activeCount = _plugins.Count(p => p.Value.State == PluginLifecycleState.Active);
|
||||
_logger.LogInformation(
|
||||
"Plugin host started with {ActiveCount}/{TotalCount} active plugins",
|
||||
activeCount, _plugins.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping plugin host...");
|
||||
|
||||
// Cancel ongoing operations
|
||||
if (_shutdownCts != null)
|
||||
{
|
||||
await _shutdownCts.CancelAsync();
|
||||
}
|
||||
|
||||
// Stop health monitoring
|
||||
await _healthMonitor.StopAsync(ct);
|
||||
|
||||
// Unload plugins in reverse dependency order
|
||||
var manifests = _plugins.Values.Select(p => p.Manifest).ToList();
|
||||
var unloadOrder = _dependencyResolver.ResolveUnloadOrder(manifests);
|
||||
|
||||
foreach (var pluginId in unloadOrder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UnloadPluginInternalAsync(pluginId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error unloading plugin {PluginId}", pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Plugin host stopped");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LoadedPlugin> LoadPluginAsync(PluginSource source, CancellationToken ct)
|
||||
{
|
||||
await _loadLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Discover manifest from source
|
||||
var manifest = await _discovery.DiscoverSingleAsync(source, ct);
|
||||
|
||||
// Check if already loaded
|
||||
if (_plugins.ContainsKey(manifest.Info.Id))
|
||||
throw new InvalidOperationException($"Plugin {manifest.Info.Id} is already loaded");
|
||||
|
||||
return await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LoadedPlugin> LoadPluginInternalAsync(
|
||||
Abstractions.Manifest.PluginManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var pluginId = manifest.Info.Id;
|
||||
_logger.LogDebug("Loading plugin {PluginId} v{Version}", pluginId, manifest.Info.Version);
|
||||
|
||||
// Register with lifecycle manager
|
||||
_lifecycle.Register(pluginId, PluginLifecycleState.Discovered);
|
||||
|
||||
// Transition to Loading state
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Loading, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Discovered, PluginLifecycleState.Loading);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Determine trust level
|
||||
var trustLevel = DetermineTrustLevel(manifest);
|
||||
|
||||
// 2. Load assembly and create instance
|
||||
var loadResult = await _loader.LoadAsync(manifest, trustLevel, ct);
|
||||
|
||||
// 3. Create plugin context
|
||||
var shutdownToken = _shutdownCts?.Token ?? CancellationToken.None;
|
||||
var context = _contextFactory.Create(manifest, trustLevel, shutdownToken);
|
||||
|
||||
// 4. Transition to Initializing
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Initializing, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Loading, PluginLifecycleState.Initializing);
|
||||
|
||||
// 5. Initialize plugin with timeout
|
||||
using var initCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
initCts.CancelAfter(_options.InitializationTimeout);
|
||||
|
||||
await loadResult.Instance.InitializeAsync(context, initCts.Token);
|
||||
|
||||
// 6. Transition to Active
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Active, ct);
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(loadResult.Instance)
|
||||
{
|
||||
Manifest = manifest,
|
||||
LoadedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_plugins[pluginId] = loadedPlugin;
|
||||
|
||||
// 7. Register with health monitor
|
||||
_healthMonitor.RegisterPlugin(loadedPlugin);
|
||||
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Initializing, PluginLifecycleState.Active);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded plugin {PluginId} v{Version} with capabilities [{Capabilities}]",
|
||||
pluginId, manifest.Info.Version, loadedPlugin.Capabilities);
|
||||
|
||||
return loadedPlugin;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load plugin {PluginId}", pluginId);
|
||||
|
||||
var currentState = _lifecycle.GetState(pluginId) ?? PluginLifecycleState.Discovered;
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Failed, ct);
|
||||
RaiseStateChanged(pluginId, currentState, PluginLifecycleState.Failed, ex.Message);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnloadPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
await _loadLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await UnloadPluginInternalAsync(pluginId, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UnloadPluginInternalAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_plugins.TryGetValue(pluginId, out var plugin))
|
||||
return;
|
||||
|
||||
_logger.LogDebug("Unloading plugin {PluginId}", pluginId);
|
||||
|
||||
var oldState = plugin.State;
|
||||
|
||||
// Transition to Stopping
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Stopping, ct);
|
||||
RaiseStateChanged(pluginId, oldState, PluginLifecycleState.Stopping);
|
||||
|
||||
try
|
||||
{
|
||||
// Unregister from health monitor
|
||||
_healthMonitor.UnregisterPlugin(pluginId);
|
||||
|
||||
// Dispose plugin with timeout
|
||||
using var disposeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
disposeCts.CancelAfter(_options.ShutdownTimeout);
|
||||
|
||||
await plugin.DisposeAsync();
|
||||
|
||||
// Transition to Stopped
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Stopped, ct);
|
||||
RaiseStateChanged(pluginId, PluginLifecycleState.Stopping, PluginLifecycleState.Stopped);
|
||||
|
||||
// Unload assembly
|
||||
await _loader.UnloadAsync(pluginId, ct);
|
||||
|
||||
// Remove from registry
|
||||
_plugins.TryRemove(pluginId, out _);
|
||||
_lifecycle.Unregister(pluginId);
|
||||
|
||||
_logger.LogInformation("Unloaded plugin {PluginId}", pluginId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error unloading plugin {PluginId}", pluginId);
|
||||
await _lifecycle.TransitionAsync(pluginId, PluginLifecycleState.Failed, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LoadedPlugin> ReloadPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
if (!_plugins.TryGetValue(pluginId, out var existing))
|
||||
throw new InvalidOperationException($"Plugin {pluginId} is not loaded");
|
||||
|
||||
var manifest = existing.Manifest
|
||||
?? throw new InvalidOperationException($"Plugin {pluginId} has no manifest");
|
||||
|
||||
await UnloadPluginAsync(pluginId, ct);
|
||||
|
||||
// Small delay to allow resources to be released
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
return await LoadPluginInternalAsync(manifest, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<T> GetPluginsWithCapability<T>() where T : class
|
||||
{
|
||||
foreach (var plugin in _plugins.Values)
|
||||
{
|
||||
if (plugin.State == PluginLifecycleState.Active && plugin.HasCapability<T>())
|
||||
{
|
||||
var capability = plugin.GetCapability<T>();
|
||||
if (capability != null)
|
||||
yield return capability;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LoadedPlugin? GetPlugin(string pluginId) =>
|
||||
_plugins.TryGetValue(pluginId, out var plugin) ? plugin : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetCapability<T>(string pluginId) where T : class =>
|
||||
GetPlugin(pluginId)?.GetCapability<T>();
|
||||
|
||||
private PluginTrustLevel DetermineTrustLevel(Abstractions.Manifest.PluginManifest manifest)
|
||||
{
|
||||
// Built-in plugins are always trusted
|
||||
if (_options.BuiltInPluginIds.Contains(manifest.Info.Id))
|
||||
return PluginTrustLevel.BuiltIn;
|
||||
|
||||
// Check trusted plugins list
|
||||
if (_options.TrustedPluginIds.Contains(manifest.Info.Id))
|
||||
return PluginTrustLevel.Trusted;
|
||||
|
||||
// Check trusted vendors
|
||||
if (_options.TrustedVendors.Contains(manifest.Info.Vendor))
|
||||
return PluginTrustLevel.Trusted;
|
||||
|
||||
// Default to untrusted
|
||||
return PluginTrustLevel.Untrusted;
|
||||
}
|
||||
|
||||
private void OnPluginHealthChanged(object? sender, PluginHealthChangedEventArgs e)
|
||||
{
|
||||
// Handle unhealthy plugins
|
||||
if (e.NewStatus == HealthStatus.Unhealthy && _options.AutoRecoverUnhealthyPlugins)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Plugin {PluginId} unhealthy, attempting recovery", e.PluginId);
|
||||
await ReloadPluginAsync(e.PluginId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to recover plugin {PluginId}", e.PluginId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
PluginHealthChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private void OnPluginStateTransition(object? sender, PluginStateTransitionEventArgs e)
|
||||
{
|
||||
// Already handled through RaiseStateChanged
|
||||
}
|
||||
|
||||
private void RaiseStateChanged(
|
||||
string pluginId,
|
||||
PluginLifecycleState oldState,
|
||||
PluginLifecycleState newState,
|
||||
string? reason = null)
|
||||
{
|
||||
PluginStateChanged?.Invoke(this, new PluginStateChangedEventArgs
|
||||
{
|
||||
PluginId = pluginId,
|
||||
OldState = oldState,
|
||||
NewState = newState,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None);
|
||||
|
||||
_healthMonitor.HealthChanged -= OnPluginHealthChanged;
|
||||
_lifecycle.StateChanged -= OnPluginStateTransition;
|
||||
|
||||
_shutdownCts?.Dispose();
|
||||
_loadLock.Dispose();
|
||||
}
|
||||
}
|
||||
118
src/Plugin/StellaOps.Plugin.Host/PluginHostOptions.cs
Normal file
118
src/Plugin/StellaOps.Plugin.Host/PluginHostOptions.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Plugin.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the plugin host.
|
||||
/// </summary>
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Plugins";
|
||||
|
||||
/// <summary>
|
||||
/// Paths to search for plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PluginPaths { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// IDs of built-in plugins (always trusted).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> BuiltInPluginIds { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// IDs of explicitly trusted plugins.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedPluginIds { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Vendor names that are trusted.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> TrustedVendors { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for plugin initialization.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for plugin shutdown.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Interval between health checks.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:05", "01:00:00")]
|
||||
public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual health checks.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:01:00")]
|
||||
public TimeSpan HealthCheckTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail startup if any plugin fails to load.
|
||||
/// </summary>
|
||||
public bool FailOnPluginLoadError { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically reload unhealthy plugins.
|
||||
/// </summary>
|
||||
public bool AutoRecoverUnhealthyPlugins { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before attempting recovery.
|
||||
/// </summary>
|
||||
[Range(1, 10)]
|
||||
public int ConsecutiveFailuresBeforeRecovery { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of recovery attempts per plugin.
|
||||
/// </summary>
|
||||
[Range(0, 10)]
|
||||
public int MaxRecoveryAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay between recovery attempts.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:10:00")]
|
||||
public TimeSpan RecoveryDelay { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Enable plugin isolation using separate AssemblyLoadContext.
|
||||
/// </summary>
|
||||
public bool EnableAssemblyIsolation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to load plugins in parallel.
|
||||
/// </summary>
|
||||
public bool ParallelLoading { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum degree of parallelism for parallel loading.
|
||||
/// </summary>
|
||||
[Range(1, 32)]
|
||||
public int MaxParallelLoads { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable hot reload support.
|
||||
/// </summary>
|
||||
public bool EnableHotReload { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// File patterns to watch for hot reload.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> HotReloadPatterns { get; set; } = ["*.dll"];
|
||||
|
||||
/// <summary>
|
||||
/// Debounce interval for hot reload file changes.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:00.100", "00:00:10")]
|
||||
public TimeSpan HotReloadDebounce { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>StellaOps.Plugin.Host</PackageId>
|
||||
<Description>Plugin host and lifecycle manager for the Stella Ops platform</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering plugin registry services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the plugin registry to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<PluginRegistryOptions>()
|
||||
.Bind(configuration.GetSection(PluginRegistryOptions.SectionName));
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Configure NpgsqlDataSource
|
||||
services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PluginRegistryOptions>>().Value;
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(options.ConnectionString);
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin registry with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<PluginRegistryOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Configure NpgsqlDataSource
|
||||
services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PluginRegistryOptions>>().Value;
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(options.ConnectionString);
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the plugin registry with an existing data source.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="dataSource">The NpgsqlDataSource to use.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginRegistry(
|
||||
this IServiceCollection services,
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
// Add default options
|
||||
services.AddOptions<PluginRegistryOptions>();
|
||||
|
||||
// Add time provider if not already registered
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Use provided data source
|
||||
services.AddSingleton(dataSource);
|
||||
|
||||
// Register the registry
|
||||
services.AddScoped<IPluginRegistry, PostgresPluginRegistry>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PluginRegistryMigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
143
src/Plugin/StellaOps.Plugin.Registry/IPluginRegistry.cs
Normal file
143
src/Plugin/StellaOps.Plugin.Registry/IPluginRegistry.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Database-backed plugin registry for persistent plugin management.
|
||||
/// </summary>
|
||||
public interface IPluginRegistry
|
||||
{
|
||||
// ========== Plugin Management ==========
|
||||
|
||||
/// <summary>
|
||||
/// Register a loaded plugin in the database.
|
||||
/// </summary>
|
||||
Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update plugin status.
|
||||
/// </summary>
|
||||
Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update plugin health status.
|
||||
/// </summary>
|
||||
Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unregister a plugin.
|
||||
/// </summary>
|
||||
Task UnregisterAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugin by ID.
|
||||
/// </summary>
|
||||
Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get all registered plugins.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct);
|
||||
|
||||
// ========== Capability Queries ==========
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins with a specific capability.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugins providing a specific capability type/id.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(string capabilityType, string? capabilityId = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Register plugin capabilities.
|
||||
/// </summary>
|
||||
Task RegisterCapabilitiesAsync(Guid pluginDbId, IEnumerable<PluginCapabilityRecord> capabilities, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get capabilities for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
// ========== Instance Management ==========
|
||||
|
||||
/// <summary>
|
||||
/// Create a tenant-specific plugin instance.
|
||||
/// </summary>
|
||||
Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get plugin instance.
|
||||
/// </summary>
|
||||
Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get instances for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get instances for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Update instance configuration.
|
||||
/// </summary>
|
||||
Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable instance.
|
||||
/// </summary>
|
||||
Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Delete instance.
|
||||
/// </summary>
|
||||
Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct);
|
||||
|
||||
// ========== Health History ==========
|
||||
|
||||
/// <summary>
|
||||
/// Record health check result.
|
||||
/// </summary>
|
||||
Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get health history for a plugin.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a plugin instance.
|
||||
/// </summary>
|
||||
/// <param name="PluginId">Plugin identifier.</param>
|
||||
/// <param name="TenantId">Optional tenant ID for multi-tenant isolation.</param>
|
||||
/// <param name="InstanceName">Optional instance name.</param>
|
||||
/// <param name="Config">Instance configuration as JSON.</param>
|
||||
/// <param name="SecretsPath">Path to secrets location.</param>
|
||||
/// <param name="ResourceLimits">Optional resource limits.</param>
|
||||
public sealed record CreatePluginInstanceRequest(
|
||||
string PluginId,
|
||||
Guid? TenantId,
|
||||
string? InstanceName,
|
||||
JsonDocument Config,
|
||||
string? SecretsPath = null,
|
||||
JsonDocument? ResourceLimits = null);
|
||||
425
src/Plugin/StellaOps.Plugin.Registry/InMemoryPluginRegistry.cs
Normal file
425
src/Plugin/StellaOps.Plugin.Registry/InMemoryPluginRegistry.cs
Normal file
@@ -0,0 +1,425 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the plugin registry for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPluginRegistry : IPluginRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, PluginRecord> _plugins = new();
|
||||
private readonly ConcurrentDictionary<Guid, PluginCapabilityRecord> _capabilities = new();
|
||||
private readonly ConcurrentDictionary<Guid, PluginInstanceRecord> _instances = new();
|
||||
private readonly ConcurrentDictionary<Guid, List<PluginHealthRecord>> _healthHistory = new();
|
||||
private readonly ILogger<InMemoryPluginRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new in-memory plugin registry instance.
|
||||
/// </summary>
|
||||
public InMemoryPluginRegistry(
|
||||
ILogger<InMemoryPluginRegistry> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
// Get current health status from last check result if available
|
||||
var healthStatus = plugin.LastHealthCheckResult?.Status ?? HealthStatus.Unknown;
|
||||
|
||||
var record = new PluginRecord
|
||||
{
|
||||
Id = id,
|
||||
PluginId = plugin.Info.Id,
|
||||
Name = plugin.Info.Name,
|
||||
Version = plugin.Info.Version,
|
||||
Vendor = plugin.Info.Vendor,
|
||||
Description = plugin.Info.Description,
|
||||
LicenseId = plugin.Info.LicenseId,
|
||||
TrustLevel = plugin.TrustLevel,
|
||||
Capabilities = plugin.Capabilities,
|
||||
Status = plugin.State,
|
||||
HealthStatus = healthStatus,
|
||||
Source = "installed",
|
||||
AssemblyPath = plugin.Manifest?.AssemblyPath,
|
||||
EntryPoint = plugin.Manifest?.EntryPoint,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
LoadedAt = plugin.LoadedAt
|
||||
};
|
||||
|
||||
_plugins[id] = record;
|
||||
_logger.LogDebug("Registered plugin {PluginId} with ID {Id}", plugin.Info.Id, id);
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
var updated = new PluginRecord
|
||||
{
|
||||
Id = record.Id,
|
||||
PluginId = record.PluginId,
|
||||
Name = record.Name,
|
||||
Version = record.Version,
|
||||
Vendor = record.Vendor,
|
||||
Description = record.Description,
|
||||
LicenseId = record.LicenseId,
|
||||
TrustLevel = record.TrustLevel,
|
||||
Capabilities = record.Capabilities,
|
||||
Status = status,
|
||||
StatusMessage = message,
|
||||
HealthStatus = record.HealthStatus,
|
||||
LastHealthCheck = record.LastHealthCheck,
|
||||
HealthCheckFailures = record.HealthCheckFailures,
|
||||
Source = record.Source,
|
||||
AssemblyPath = record.AssemblyPath,
|
||||
EntryPoint = record.EntryPoint,
|
||||
Manifest = record.Manifest,
|
||||
CreatedAt = record.CreatedAt,
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
LoadedAt = record.LoadedAt
|
||||
};
|
||||
_plugins[record.Id] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = new PluginRecord
|
||||
{
|
||||
Id = record.Id,
|
||||
PluginId = record.PluginId,
|
||||
Name = record.Name,
|
||||
Version = record.Version,
|
||||
Vendor = record.Vendor,
|
||||
Description = record.Description,
|
||||
LicenseId = record.LicenseId,
|
||||
TrustLevel = record.TrustLevel,
|
||||
Capabilities = record.Capabilities,
|
||||
Status = record.Status,
|
||||
StatusMessage = record.StatusMessage,
|
||||
HealthStatus = status,
|
||||
LastHealthCheck = now,
|
||||
HealthCheckFailures = status == HealthStatus.Healthy ? 0 : record.HealthCheckFailures + 1,
|
||||
Source = record.Source,
|
||||
AssemblyPath = record.AssemblyPath,
|
||||
EntryPoint = record.EntryPoint,
|
||||
Manifest = record.Manifest,
|
||||
CreatedAt = record.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
LoadedAt = record.LoadedAt
|
||||
};
|
||||
_plugins[record.Id] = updated;
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
RecordHealthCheckAsync(pluginId, result, ct).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnregisterAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (record != null)
|
||||
{
|
||||
_plugins.TryRemove(record.Id, out _);
|
||||
|
||||
// Remove capabilities
|
||||
var capsToRemove = _capabilities.Values.Where(c => c.PluginId == record.Id).ToList();
|
||||
foreach (var cap in capsToRemove)
|
||||
{
|
||||
_capabilities.TryRemove(cap.Id, out _);
|
||||
}
|
||||
|
||||
// Remove instances
|
||||
var instancesToRemove = _instances.Values.Where(i => i.PluginId == record.Id).ToList();
|
||||
foreach (var instance in instancesToRemove)
|
||||
{
|
||||
_instances.TryRemove(instance.Id, out _);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var record = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values.OrderBy(p => p.Name).ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values
|
||||
.Where(p => p.Status == status)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct)
|
||||
{
|
||||
var result = _plugins.Values
|
||||
.Where(p => (p.Capabilities & capability) != 0 && p.Status == PluginLifecycleState.Active)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(string capabilityType, string? capabilityId = null, CancellationToken ct = default)
|
||||
{
|
||||
var matchingCaps = _capabilities.Values
|
||||
.Where(c => c.CapabilityType == capabilityType && c.IsEnabled)
|
||||
.Where(c => capabilityId == null || c.CapabilityId == capabilityId);
|
||||
|
||||
var pluginIds = matchingCaps.Select(c => c.PluginId).ToHashSet();
|
||||
|
||||
var result = _plugins.Values
|
||||
.Where(p => pluginIds.Contains(p.Id) && p.Status == PluginLifecycleState.Active)
|
||||
.OrderBy(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RegisterCapabilitiesAsync(Guid pluginDbId, IEnumerable<PluginCapabilityRecord> capabilities, CancellationToken ct)
|
||||
{
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
var record = cap with { PluginId = pluginDbId };
|
||||
_capabilities[cap.Id] = record;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginCapabilityRecord>>([]);
|
||||
}
|
||||
|
||||
var result = _capabilities.Values
|
||||
.Where(c => c.PluginId == plugin.Id)
|
||||
.OrderBy(c => c.CapabilityType)
|
||||
.ThenBy(c => c.CapabilityId)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginCapabilityRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == request.PluginId)
|
||||
?? throw new InvalidOperationException($"Plugin {request.PluginId} not found");
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
var record = new PluginInstanceRecord
|
||||
{
|
||||
Id = id,
|
||||
PluginId = plugin.Id,
|
||||
PluginStringId = request.PluginId,
|
||||
TenantId = request.TenantId,
|
||||
InstanceName = request.InstanceName,
|
||||
Config = request.Config,
|
||||
SecretsPath = request.SecretsPath,
|
||||
ResourceLimits = request.ResourceLimits,
|
||||
Enabled = true,
|
||||
Status = "pending",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_instances[id] = record;
|
||||
_logger.LogDebug("Created instance {InstanceId} for plugin {PluginId}", id, request.PluginId);
|
||||
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
_instances.TryGetValue(instanceId, out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
var result = _instances.Values
|
||||
.Where(i => i.TenantId == tenantId)
|
||||
.OrderBy(i => i.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>([]);
|
||||
}
|
||||
|
||||
var result = _instances.Values
|
||||
.Where(i => i.PluginId == plugin.Id)
|
||||
.OrderBy(i => i.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PluginInstanceRecord>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct)
|
||||
{
|
||||
if (_instances.TryGetValue(instanceId, out var existing))
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Config = config,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_instances[instanceId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
if (_instances.TryGetValue(instanceId, out var existing))
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Enabled = enabled,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
_instances[instanceId] = updated;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
_instances.TryRemove(instanceId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var healthRecord = new PluginHealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PluginId = plugin.Id,
|
||||
PluginStringId = pluginId,
|
||||
CheckedAt = now,
|
||||
Status = result.Status,
|
||||
ResponseTimeMs = result.Duration.HasValue ? (int)result.Duration.Value.TotalMilliseconds : null,
|
||||
ErrorMessage = result.Message,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_healthHistory.AddOrUpdate(
|
||||
plugin.Id,
|
||||
_ => [healthRecord],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(healthRecord);
|
||||
return list;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var plugin = _plugins.Values.FirstOrDefault(p => p.PluginId == pluginId);
|
||||
if (plugin == null || !_healthHistory.TryGetValue(plugin.Id, out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PluginHealthRecord>>([]);
|
||||
}
|
||||
|
||||
var result = history
|
||||
.Where(h => h.CheckedAt >= since)
|
||||
.OrderByDescending(h => h.CheckedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PluginHealthRecord>>(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all data from the registry. For testing purposes.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_plugins.Clear();
|
||||
_capabilities.Clear();
|
||||
_instances.Clear();
|
||||
_healthHistory.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Migration: 001_CreatePluginTables
|
||||
-- Creates the core tables for the plugin registry
|
||||
|
||||
-- Ensure schema exists
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
-- Plugin registry table
|
||||
CREATE TABLE IF NOT EXISTS platform.plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
vendor VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
license_id VARCHAR(50),
|
||||
|
||||
-- Trust and security
|
||||
trust_level VARCHAR(50) NOT NULL CHECK (trust_level IN ('builtin', 'trusted', 'untrusted')),
|
||||
signature BYTEA,
|
||||
signing_key_id VARCHAR(255),
|
||||
|
||||
-- Capabilities
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
capability_details JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Source and deployment
|
||||
source VARCHAR(50) NOT NULL CHECK (source IN ('bundled', 'installed', 'discovered')),
|
||||
assembly_path VARCHAR(500),
|
||||
entry_point VARCHAR(255),
|
||||
|
||||
-- Lifecycle
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'discovered' CHECK (status IN (
|
||||
'discovered', 'loading', 'initializing', 'active',
|
||||
'degraded', 'stopping', 'stopped', 'failed', 'unloading'
|
||||
)),
|
||||
status_message TEXT,
|
||||
|
||||
-- Health
|
||||
health_status VARCHAR(50) DEFAULT 'unknown' CHECK (health_status IN (
|
||||
'unknown', 'healthy', 'degraded', 'unhealthy'
|
||||
)),
|
||||
last_health_check TIMESTAMPTZ,
|
||||
health_check_failures INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Metadata
|
||||
manifest JSONB,
|
||||
runtime_info JSONB,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
loaded_at TIMESTAMPTZ,
|
||||
|
||||
UNIQUE(plugin_id, version)
|
||||
);
|
||||
|
||||
-- Plugin capabilities
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_capabilities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
|
||||
capability_type VARCHAR(100) NOT NULL,
|
||||
capability_id VARCHAR(255) NOT NULL,
|
||||
|
||||
config_schema JSONB,
|
||||
input_schema JSONB,
|
||||
output_schema JSONB,
|
||||
|
||||
display_name VARCHAR(255),
|
||||
description TEXT,
|
||||
documentation_url VARCHAR(500),
|
||||
metadata JSONB,
|
||||
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE(plugin_id, capability_type, capability_id)
|
||||
);
|
||||
|
||||
-- Plugin instances (for multi-tenant)
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_instances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
tenant_id UUID,
|
||||
|
||||
instance_name VARCHAR(255),
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
secrets_path VARCHAR(500),
|
||||
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
|
||||
resource_limits JSONB,
|
||||
|
||||
last_used_at TIMESTAMPTZ,
|
||||
invocation_count BIGINT NOT NULL DEFAULT 0,
|
||||
error_count BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE(plugin_id, tenant_id, COALESCE(instance_name, ''))
|
||||
);
|
||||
|
||||
-- Plugin health history
|
||||
CREATE TABLE IF NOT EXISTS platform.plugin_health_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plugin_id UUID NOT NULL REFERENCES platform.plugins(id) ON DELETE CASCADE,
|
||||
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status VARCHAR(50) NOT NULL,
|
||||
response_time_ms INT,
|
||||
details JSONB,
|
||||
error_message TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_plugin_id ON platform.plugins(plugin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_status ON platform.plugins(status) WHERE status != 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_trust_level ON platform.plugins(trust_level);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_capabilities ON platform.plugins USING GIN (capabilities);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugins_health ON platform.plugins(health_status) WHERE health_status != 'healthy';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_type ON platform.plugin_capabilities(capability_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_lookup ON platform.plugin_capabilities(capability_type, capability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_capabilities_plugin ON platform.plugin_capabilities(plugin_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_tenant ON platform.plugin_instances(tenant_id) WHERE tenant_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_plugin ON platform.plugin_instances(plugin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_instances_enabled ON platform.plugin_instances(plugin_id, enabled) WHERE enabled = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_health_history_plugin ON platform.plugin_health_history(plugin_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_health_history_checked ON platform.plugin_health_history(checked_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE platform.plugins IS 'Registry of all plugins known to the system';
|
||||
COMMENT ON TABLE platform.plugin_capabilities IS 'Detailed capabilities exposed by each plugin';
|
||||
COMMENT ON TABLE platform.plugin_instances IS 'Tenant-specific plugin instances and configurations';
|
||||
COMMENT ON TABLE platform.plugin_health_history IS 'Historical health check results for plugins';
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin capability record in the database.
|
||||
/// </summary>
|
||||
public sealed record PluginCapabilityRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin.
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability type (e.g., "crypto", "auth", "scm").
|
||||
/// </summary>
|
||||
public required string CapabilityType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability identifier (e.g., specific algorithm or provider).
|
||||
/// </summary>
|
||||
public required string CapabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the capability.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capability description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability configuration.
|
||||
/// </summary>
|
||||
public JsonDocument? ConfigSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability input.
|
||||
/// </summary>
|
||||
public JsonDocument? InputSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON schema for capability output.
|
||||
/// </summary>
|
||||
public JsonDocument? OutputSchema { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to capability documentation.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this capability is enabled.
|
||||
/// </summary>
|
||||
public required bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin health history record in the database.
|
||||
/// </summary>
|
||||
public sealed class PluginHealthRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin (database ID).
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin string identifier (denormalized for queries).
|
||||
/// </summary>
|
||||
public string? PluginStringId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the health check was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health status result.
|
||||
/// </summary>
|
||||
public required HealthStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Response time in milliseconds.
|
||||
/// </summary>
|
||||
public int? ResponseTimeMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Health check details as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if health check failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin instance record in the database.
|
||||
/// </summary>
|
||||
public sealed record PluginInstanceRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the plugin (database ID).
|
||||
/// </summary>
|
||||
public required Guid PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin string identifier.
|
||||
/// </summary>
|
||||
public string? PluginStringId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenant isolation.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance name for identification.
|
||||
/// </summary>
|
||||
public string? InstanceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance configuration as JSON.
|
||||
/// </summary>
|
||||
public required JsonDocument Config { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to secrets location.
|
||||
/// </summary>
|
||||
public string? SecretsPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? ResourceLimits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the instance is enabled.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Instance status.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last time the instance was used.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total invocation count.
|
||||
/// </summary>
|
||||
public long InvocationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total error count.
|
||||
/// </summary>
|
||||
public long ErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record last update timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
122
src/Plugin/StellaOps.Plugin.Registry/Models/PluginRecord.cs
Normal file
122
src/Plugin/StellaOps.Plugin.Registry/Models/PluginRecord.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
namespace StellaOps.Plugin.Registry.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin record in the database.
|
||||
/// </summary>
|
||||
public sealed class PluginRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Database-generated unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin identifier (from manifest).
|
||||
/// </summary>
|
||||
public required string PluginId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin display name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin version (semver).
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin vendor/author.
|
||||
/// </summary>
|
||||
public required string Vendor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SPDX license identifier.
|
||||
/// </summary>
|
||||
public string? LicenseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin trust level.
|
||||
/// </summary>
|
||||
public required PluginTrustLevel TrustLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin capabilities flags.
|
||||
/// </summary>
|
||||
public required PluginCapabilities Capabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin lifecycle state.
|
||||
/// </summary>
|
||||
public required PluginLifecycleState Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional status message.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current health status.
|
||||
/// </summary>
|
||||
public required HealthStatus HealthStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last health check timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastHealthCheck { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Consecutive health check failures.
|
||||
/// </summary>
|
||||
public int HealthCheckFailures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (bundled, installed, discovered).
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly file path.
|
||||
/// </summary>
|
||||
public string? AssemblyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point type name.
|
||||
/// </summary>
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full manifest as JSON.
|
||||
/// </summary>
|
||||
public JsonDocument? Manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime information.
|
||||
/// </summary>
|
||||
public JsonDocument? RuntimeInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record creation timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record last update timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin load timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LoadedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Runs database migrations for the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryMigrationRunner
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PluginRegistryMigrationRunner> _logger;
|
||||
private readonly PluginRegistryOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new migration runner instance.
|
||||
/// </summary>
|
||||
public PluginRegistryMigrationRunner(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PluginRegistryMigrationRunner> logger,
|
||||
IOptions<PluginRegistryOptions> options)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public async Task RunMigrationsAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Running plugin registry migrations...");
|
||||
|
||||
await EnsureMigrationTableExistsAsync(ct);
|
||||
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(ct);
|
||||
var pendingMigrations = GetPendingMigrations(appliedMigrations);
|
||||
|
||||
if (pendingMigrations.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No pending migrations");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var migration in pendingMigrations)
|
||||
{
|
||||
await ApplyMigrationAsync(migration, ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applied {Count} migrations", pendingMigrations.Count);
|
||||
}
|
||||
|
||||
private async Task EnsureMigrationTableExistsAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
CREATE TABLE IF NOT EXISTS {_options.SchemaName}.plugin_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_name VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""";
|
||||
|
||||
// First ensure schema exists
|
||||
var schemaSql = $"CREATE SCHEMA IF NOT EXISTS {_options.SchemaName}";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
await using var schemaCmd = new NpgsqlCommand(schemaSql, conn);
|
||||
await schemaCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetAppliedMigrationsAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT migration_name FROM {_options.SchemaName}.plugin_migrations";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
applied.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
private List<(string Name, string Sql)> GetPendingMigrations(HashSet<string> appliedMigrations)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourcePrefix = "StellaOps.Plugin.Registry.Migrations.";
|
||||
|
||||
var pending = new List<(string Name, string Sql)>();
|
||||
|
||||
foreach (var resourceName in assembly.GetManifestResourceNames()
|
||||
.Where(n => n.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase) && n.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(n => n))
|
||||
{
|
||||
var migrationName = Path.GetFileNameWithoutExtension(resourceName[(resourcePrefix.Length)..]);
|
||||
|
||||
if (appliedMigrations.Contains(migrationName))
|
||||
continue;
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = reader.ReadToEnd();
|
||||
|
||||
pending.Add((migrationName, sql));
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync((string Name, string Sql) migration, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Applying migration: {MigrationName}", migration.Name);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Execute migration SQL
|
||||
await using var cmd = new NpgsqlCommand(migration.Sql, conn, transaction);
|
||||
cmd.CommandTimeout = (int)_options.CommandTimeout.TotalSeconds;
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Record migration
|
||||
await using var recordCmd = new NpgsqlCommand(
|
||||
$"INSERT INTO {_options.SchemaName}.plugin_migrations (migration_name) VALUES (@name)",
|
||||
conn,
|
||||
transaction);
|
||||
recordCmd.Parameters.AddWithValue("name", migration.Name);
|
||||
await recordCmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
_logger.LogInformation("Applied migration: {MigrationName}", migration.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Failed to apply migration: {MigrationName}", migration.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PluginRegistryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "PluginRegistry";
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL connection string.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Schema name for plugin tables.
|
||||
/// </summary>
|
||||
public string SchemaName { get; set; } = "platform";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum health history records to retain per plugin.
|
||||
/// </summary>
|
||||
public int MaxHealthHistoryRecords { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Health history retention period.
|
||||
/// </summary>
|
||||
public TimeSpan HealthHistoryRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to run migrations on startup.
|
||||
/// </summary>
|
||||
public bool RunMigrationsOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout for database operations.
|
||||
/// </summary>
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
734
src/Plugin/StellaOps.Plugin.Registry/PostgresPluginRegistry.cs
Normal file
734
src/Plugin/StellaOps.Plugin.Registry/PostgresPluginRegistry.cs
Normal file
@@ -0,0 +1,734 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Execution;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Registry.Models;
|
||||
|
||||
namespace StellaOps.Plugin.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the plugin registry.
|
||||
/// </summary>
|
||||
public sealed class PostgresPluginRegistry : IPluginRegistry
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresPluginRegistry> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly PluginRegistryOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL plugin registry instance.
|
||||
/// </summary>
|
||||
public PostgresPluginRegistry(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresPluginRegistry> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PluginRegistryOptions> options)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginRecord> RegisterAsync(LoadedPlugin plugin, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugins (
|
||||
plugin_id, name, version, vendor, description, license_id,
|
||||
trust_level, capabilities, capability_details, source,
|
||||
assembly_path, entry_point, status, manifest, created_at, updated_at, loaded_at
|
||||
) VALUES (
|
||||
@plugin_id, @name, @version, @vendor, @description, @license_id,
|
||||
@trust_level, @capabilities, @capability_details::jsonb, @source,
|
||||
@assembly_path, @entry_point, @status, @manifest::jsonb, @now, @now, @now
|
||||
)
|
||||
ON CONFLICT (plugin_id, version) DO UPDATE SET
|
||||
status = @status,
|
||||
updated_at = @now,
|
||||
loaded_at = @now
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", plugin.Info.Id);
|
||||
cmd.Parameters.AddWithValue("name", plugin.Info.Name);
|
||||
cmd.Parameters.AddWithValue("version", plugin.Info.Version);
|
||||
cmd.Parameters.AddWithValue("vendor", plugin.Info.Vendor);
|
||||
cmd.Parameters.AddWithValue("description", (object?)plugin.Info.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("license_id", (object?)plugin.Info.LicenseId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("trust_level", plugin.TrustLevel.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("capabilities", plugin.Capabilities.ToStringArray());
|
||||
cmd.Parameters.AddWithValue("capability_details", "{}");
|
||||
cmd.Parameters.AddWithValue("source", "installed");
|
||||
cmd.Parameters.AddWithValue("assembly_path", (object?)plugin.Manifest?.AssemblyPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("entry_point", (object?)plugin.Manifest?.EntryPoint ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", plugin.State.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("manifest", plugin.Manifest != null
|
||||
? JsonSerializer.Serialize(plugin.Manifest, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var record = MapPluginRecord(reader);
|
||||
|
||||
// Register capabilities
|
||||
if (plugin.Manifest?.Capabilities.Count > 0)
|
||||
{
|
||||
await reader.CloseAsync();
|
||||
var capRecords = plugin.Manifest.Capabilities.Select(c => new PluginCapabilityRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PluginId = record.Id,
|
||||
CapabilityType = c.Type,
|
||||
CapabilityId = c.Id ?? c.Type,
|
||||
DisplayName = c.DisplayName,
|
||||
Description = c.Description,
|
||||
Metadata = c.Metadata,
|
||||
IsEnabled = true,
|
||||
CreatedAt = now
|
||||
});
|
||||
|
||||
await RegisterCapabilitiesAsync(record.Id, capRecords, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Registered plugin {PluginId} with DB ID {DbId}", plugin.Info.Id, record.Id);
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to register plugin {plugin.Info.Id}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateStatusAsync(string pluginId, PluginLifecycleState status, string? message = null, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugins
|
||||
SET status = @status, status_message = @message, updated_at = @now
|
||||
WHERE plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("message", (object?)message ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct);
|
||||
if (rows > 0)
|
||||
{
|
||||
_logger.LogDebug("Updated plugin {PluginId} status to {Status}", pluginId, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateHealthAsync(string pluginId, HealthStatus status, HealthCheckResult? result = null, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugins
|
||||
SET health_status = @health_status,
|
||||
last_health_check = @now,
|
||||
updated_at = @now,
|
||||
health_check_failures = CASE
|
||||
WHEN @health_status = 'healthy' THEN 0
|
||||
ELSE health_check_failures + 1
|
||||
END
|
||||
WHERE plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("health_status", status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
// Record health history
|
||||
if (result != null)
|
||||
{
|
||||
await RecordHealthCheckAsync(pluginId, result, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnregisterAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"DELETE FROM {_options.SchemaName}.plugins WHERE plugin_id = @plugin_id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
_logger.LogDebug("Unregistered plugin {PluginId}", pluginId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginRecord?> GetAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugins WHERE plugin_id = @plugin_id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapPluginRecord(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugins ORDER BY name";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByStatusAsync(PluginLifecycleState status, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugins
|
||||
WHERE status = @status
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityAsync(PluginCapabilities capability, CancellationToken ct)
|
||||
{
|
||||
var capabilityStrings = capability.ToStringArray();
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugins
|
||||
WHERE capabilities && @capabilities
|
||||
AND status = 'active'
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("capabilities", capabilityStrings);
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginRecord>> GetByCapabilityTypeAsync(
|
||||
string capabilityType,
|
||||
string? capabilityId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT p.* FROM {_options.SchemaName}.plugins p
|
||||
INNER JOIN {_options.SchemaName}.plugin_capabilities c ON c.plugin_id = p.id
|
||||
WHERE c.capability_type = @capability_type
|
||||
AND c.is_enabled = TRUE
|
||||
AND p.status = 'active'
|
||||
""";
|
||||
|
||||
if (capabilityId != null)
|
||||
{
|
||||
sql += " AND c.capability_id = @capability_id";
|
||||
}
|
||||
|
||||
sql += " ORDER BY p.name";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("capability_type", capabilityType);
|
||||
if (capabilityId != null)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("capability_id", capabilityId);
|
||||
}
|
||||
|
||||
var results = new List<PluginRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapPluginRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RegisterCapabilitiesAsync(
|
||||
Guid pluginDbId,
|
||||
IEnumerable<PluginCapabilityRecord> capabilities,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_capabilities (
|
||||
id, plugin_id, capability_type, capability_id,
|
||||
display_name, description, config_schema, metadata, is_enabled, created_at
|
||||
) VALUES (
|
||||
@id, @plugin_id, @capability_type, @capability_id,
|
||||
@display_name, @description, @config_schema::jsonb, @metadata::jsonb, @is_enabled, @created_at
|
||||
)
|
||||
ON CONFLICT (plugin_id, capability_type, capability_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
description = EXCLUDED.description,
|
||||
config_schema = EXCLUDED.config_schema,
|
||||
metadata = EXCLUDED.metadata
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
foreach (var cap in capabilities)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", cap.Id);
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginDbId);
|
||||
cmd.Parameters.AddWithValue("capability_type", cap.CapabilityType);
|
||||
cmd.Parameters.AddWithValue("capability_id", cap.CapabilityId);
|
||||
cmd.Parameters.AddWithValue("display_name", (object?)cap.DisplayName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)cap.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("config_schema", cap.ConfigSchema != null
|
||||
? JsonSerializer.Serialize(cap.ConfigSchema, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("metadata", cap.Metadata != null
|
||||
? JsonSerializer.Serialize(cap.Metadata, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_enabled", cap.IsEnabled);
|
||||
cmd.Parameters.AddWithValue("created_at", cap.CreatedAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginCapabilityRecord>> GetCapabilitiesAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT c.* FROM {_options.SchemaName}.plugin_capabilities c
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = c.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
ORDER BY c.capability_type, c.capability_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
var results = new List<PluginCapabilityRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapCapabilityRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ========== Instance Management ==========
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginInstanceRecord> CreateInstanceAsync(CreatePluginInstanceRequest request, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_instances (
|
||||
plugin_id, tenant_id, instance_name, config, secrets_path,
|
||||
resource_limits, enabled, status, created_at, updated_at
|
||||
)
|
||||
SELECT p.id, @tenant_id, @instance_name, @config::jsonb, @secrets_path,
|
||||
@resource_limits::jsonb, TRUE, 'pending', @now, @now
|
||||
FROM {_options.SchemaName}.plugins p
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", request.PluginId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", (object?)request.TenantId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("instance_name", (object?)request.InstanceName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("config", JsonSerializer.Serialize(request.Config, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("secrets_path", (object?)request.SecretsPath ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("resource_limits", request.ResourceLimits != null
|
||||
? JsonSerializer.Serialize(request.ResourceLimits, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("now", now);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
var record = MapInstanceRecord(reader);
|
||||
_logger.LogDebug("Created instance {InstanceId} for plugin {PluginId}", record.Id, request.PluginId);
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Failed to create instance for plugin {request.PluginId}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PluginInstanceRecord?> GetInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"SELECT * FROM {_options.SchemaName}.plugin_instances WHERE id = @id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapInstanceRecord(reader) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForTenantAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT * FROM {_options.SchemaName}.plugin_instances
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var results = new List<PluginInstanceRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapInstanceRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginInstanceRecord>> GetInstancesForPluginAsync(string pluginId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT i.* FROM {_options.SchemaName}.plugin_instances i
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = i.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
ORDER BY i.created_at
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
|
||||
var results = new List<PluginInstanceRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapInstanceRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateInstanceConfigAsync(Guid instanceId, JsonDocument config, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugin_instances
|
||||
SET config = @config::jsonb, updated_at = @now
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
cmd.Parameters.AddWithValue("config", JsonSerializer.Serialize(config, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_logger.LogDebug("Updated config for instance {InstanceId}", instanceId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetInstanceEnabledAsync(Guid instanceId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
UPDATE {_options.SchemaName}.plugin_instances
|
||||
SET enabled = @enabled, updated_at = @now
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
cmd.Parameters.AddWithValue("enabled", enabled);
|
||||
cmd.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
_logger.LogDebug("Set instance {InstanceId} enabled={Enabled}", instanceId, enabled);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteInstanceAsync(Guid instanceId, CancellationToken ct)
|
||||
{
|
||||
var sql = $"DELETE FROM {_options.SchemaName}.plugin_instances WHERE id = @id";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", instanceId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
_logger.LogDebug("Deleted instance {InstanceId}", instanceId);
|
||||
}
|
||||
|
||||
// ========== Health History ==========
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordHealthCheckAsync(string pluginId, HealthCheckResult result, CancellationToken ct)
|
||||
{
|
||||
var sql = $"""
|
||||
INSERT INTO {_options.SchemaName}.plugin_health_history (
|
||||
plugin_id, checked_at, status, response_time_ms, details, error_message, created_at
|
||||
)
|
||||
SELECT p.id, @checked_at, @status, @response_time_ms, @details::jsonb, @error_message, @checked_at
|
||||
FROM {_options.SchemaName}.plugins p
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("checked_at", now);
|
||||
cmd.Parameters.AddWithValue("status", result.Status.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("response_time_ms", (int)(result.Duration?.TotalMilliseconds ?? 0));
|
||||
cmd.Parameters.AddWithValue("details", result.Details != null
|
||||
? JsonSerializer.Serialize(result.Details, JsonOptions)
|
||||
: DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("error_message", (object?)result.Message ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PluginHealthRecord>> GetHealthHistoryAsync(
|
||||
string pluginId,
|
||||
DateTimeOffset since,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT h.*, p.plugin_id as plugin_string_id
|
||||
FROM {_options.SchemaName}.plugin_health_history h
|
||||
INNER JOIN {_options.SchemaName}.plugins p ON p.id = h.plugin_id
|
||||
WHERE p.plugin_id = @plugin_id
|
||||
AND h.checked_at >= @since
|
||||
ORDER BY h.checked_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("plugin_id", pluginId);
|
||||
cmd.Parameters.AddWithValue("since", since);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var results = new List<PluginHealthRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapHealthRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ========== Mapping ==========
|
||||
|
||||
private static PluginRecord MapPluginRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetString(reader.GetOrdinal("plugin_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Version = reader.GetString(reader.GetOrdinal("version")),
|
||||
Vendor = reader.GetString(reader.GetOrdinal("vendor")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("description")),
|
||||
LicenseId = reader.IsDBNull(reader.GetOrdinal("license_id"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("license_id")),
|
||||
TrustLevel = Enum.Parse<PluginTrustLevel>(reader.GetString(reader.GetOrdinal("trust_level")), ignoreCase: true),
|
||||
Capabilities = PluginCapabilitiesExtensions.FromStringArray(
|
||||
reader.GetFieldValue<string[]>(reader.GetOrdinal("capabilities"))),
|
||||
Status = Enum.Parse<PluginLifecycleState>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
|
||||
StatusMessage = reader.IsDBNull(reader.GetOrdinal("status_message"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("status_message")),
|
||||
HealthStatus = reader.IsDBNull(reader.GetOrdinal("health_status"))
|
||||
? HealthStatus.Unknown
|
||||
: Enum.Parse<HealthStatus>(reader.GetString(reader.GetOrdinal("health_status")), ignoreCase: true),
|
||||
LastHealthCheck = reader.IsDBNull(reader.GetOrdinal("last_health_check"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_health_check")),
|
||||
HealthCheckFailures = reader.IsDBNull(reader.GetOrdinal("health_check_failures"))
|
||||
? 0
|
||||
: reader.GetInt32(reader.GetOrdinal("health_check_failures")),
|
||||
Source = reader.GetString(reader.GetOrdinal("source")),
|
||||
AssemblyPath = reader.IsDBNull(reader.GetOrdinal("assembly_path"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("assembly_path")),
|
||||
EntryPoint = reader.IsDBNull(reader.GetOrdinal("entry_point"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("entry_point")),
|
||||
Manifest = reader.IsDBNull(reader.GetOrdinal("manifest"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("manifest"))),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
LoadedAt = reader.IsDBNull(reader.GetOrdinal("loaded_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("loaded_at"))
|
||||
};
|
||||
|
||||
private static PluginCapabilityRecord MapCapabilityRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
CapabilityType = reader.GetString(reader.GetOrdinal("capability_type")),
|
||||
CapabilityId = reader.GetString(reader.GetOrdinal("capability_id")),
|
||||
DisplayName = reader.IsDBNull(reader.GetOrdinal("display_name"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("display_name")),
|
||||
Description = reader.IsDBNull(reader.GetOrdinal("description"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("description")),
|
||||
ConfigSchema = reader.IsDBNull(reader.GetOrdinal("config_schema"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("config_schema"))),
|
||||
IsEnabled = reader.GetBoolean(reader.GetOrdinal("is_enabled")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
|
||||
private static PluginInstanceRecord MapInstanceRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
TenantId = reader.IsDBNull(reader.GetOrdinal("tenant_id"))
|
||||
? null
|
||||
: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
InstanceName = reader.IsDBNull(reader.GetOrdinal("instance_name"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("instance_name")),
|
||||
Config = JsonDocument.Parse(reader.GetString(reader.GetOrdinal("config"))),
|
||||
SecretsPath = reader.IsDBNull(reader.GetOrdinal("secrets_path"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("secrets_path")),
|
||||
ResourceLimits = reader.IsDBNull(reader.GetOrdinal("resource_limits"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("resource_limits"))),
|
||||
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
|
||||
Status = reader.GetString(reader.GetOrdinal("status")),
|
||||
LastUsedAt = reader.IsDBNull(reader.GetOrdinal("last_used_at"))
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_used_at")),
|
||||
InvocationCount = reader.IsDBNull(reader.GetOrdinal("invocation_count"))
|
||||
? 0
|
||||
: reader.GetInt64(reader.GetOrdinal("invocation_count")),
|
||||
ErrorCount = reader.IsDBNull(reader.GetOrdinal("error_count"))
|
||||
? 0
|
||||
: reader.GetInt64(reader.GetOrdinal("error_count")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
};
|
||||
|
||||
private static PluginHealthRecord MapHealthRecord(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PluginId = reader.GetGuid(reader.GetOrdinal("plugin_id")),
|
||||
PluginStringId = reader.IsDBNull(reader.GetOrdinal("plugin_string_id"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("plugin_string_id")),
|
||||
CheckedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("checked_at")),
|
||||
Status = Enum.Parse<HealthStatus>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
|
||||
ResponseTimeMs = reader.IsDBNull(reader.GetOrdinal("response_time_ms"))
|
||||
? null
|
||||
: reader.GetInt32(reader.GetOrdinal("response_time_ms")),
|
||||
Details = reader.IsDBNull(reader.GetOrdinal("details"))
|
||||
? null
|
||||
: JsonDocument.Parse(reader.GetString(reader.GetOrdinal("details"))),
|
||||
ErrorMessage = reader.IsDBNull(reader.GetOrdinal("error_message"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("error_message")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>StellaOps.Plugin.Registry</PackageId>
|
||||
<Description>Database-backed plugin registry for persistent plugin management</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Text.Json;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-based plugin bridge implementation.
|
||||
/// </summary>
|
||||
public sealed class GrpcPluginBridge : IGrpcPluginBridge
|
||||
{
|
||||
private readonly ILogger<GrpcPluginBridge> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private GrpcChannel? _channel;
|
||||
private PluginBridge.PluginBridgeClient? _client;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _channel != null && _channel.State == ConnectivityState.Ready;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new gRPC plugin bridge.
|
||||
/// </summary>
|
||||
public GrpcPluginBridge(ILogger<GrpcPluginBridge> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(string socketPath, CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var address = GetAddress(socketPath);
|
||||
|
||||
_logger.LogDebug("Connecting to plugin at {Address}", address);
|
||||
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, token) =>
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Named pipe connection
|
||||
var pipeStream = new System.IO.Pipes.NamedPipeClientStream(
|
||||
".",
|
||||
socketPath.Replace("\\\\.\\pipe\\", ""),
|
||||
System.IO.Pipes.PipeDirection.InOut,
|
||||
System.IO.Pipes.PipeOptions.Asynchronous);
|
||||
|
||||
await pipeStream.ConnectAsync(token);
|
||||
return pipeStream;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unix socket connection
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, token);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = handler,
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
|
||||
_client = new PluginBridge.PluginBridgeClient(_channel);
|
||||
|
||||
// Wait for connection
|
||||
await _channel.ConnectAsync(ct);
|
||||
|
||||
_logger.LogDebug("Connected to plugin at {Address}", address);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync(CancellationToken ct)
|
||||
{
|
||||
if (_channel != null)
|
||||
{
|
||||
await _channel.ShutdownAsync();
|
||||
_channel.Dispose();
|
||||
_channel = null;
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializePluginAsync(
|
||||
Abstractions.Manifest.PluginManifest manifest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, _jsonOptions);
|
||||
var configJson = string.Empty; // Configuration is handled separately
|
||||
|
||||
var request = new InitializeRequest
|
||||
{
|
||||
ManifestJson = manifestJson,
|
||||
ConfigJson = configJson,
|
||||
SandboxId = string.Empty // Will be set by the bridge
|
||||
};
|
||||
|
||||
var response = await _client!.InitializeAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin initialization failed: {response.Error}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Plugin {PluginId} initialized successfully", manifest.Info.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ShutdownPluginAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new ShutdownRequest
|
||||
{
|
||||
TimeoutMs = 30000,
|
||||
Reason = "Host requested shutdown"
|
||||
};
|
||||
|
||||
var response = await _client!.ShutdownAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
_logger.LogWarning("Plugin shutdown reported failure: {Message}", response.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<T> InvokeAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new InvokeRequest
|
||||
{
|
||||
Operation = operationName,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (parameters != null)
|
||||
{
|
||||
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
|
||||
}
|
||||
|
||||
var response = await _client!.InvokeAsync(request, cancellationToken: ct);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operationName}' failed: {response.Error}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(response.ResultJson))
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(response.ResultJson, _jsonOptions)!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new InvokeRequest
|
||||
{
|
||||
Operation = operationName,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
if (parameters != null)
|
||||
{
|
||||
request.ParametersJson = JsonSerializer.Serialize(parameters, _jsonOptions);
|
||||
}
|
||||
|
||||
using var call = _client!.InvokeStreaming(request, cancellationToken: ct);
|
||||
|
||||
await foreach (var evt in call.ResponseStream.ReadAllAsync(ct))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(evt.PayloadJson))
|
||||
{
|
||||
yield return JsonSerializer.Deserialize<TEvent>(evt.PayloadJson, _jsonOptions)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new HealthCheckRequest
|
||||
{
|
||||
IncludeDetails = true
|
||||
};
|
||||
|
||||
var response = await _client!.HealthCheckAsync(request, cancellationToken: ct);
|
||||
|
||||
var status = response.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"healthy" => HealthStatus.Healthy,
|
||||
"degraded" => HealthStatus.Degraded,
|
||||
"unhealthy" => HealthStatus.Unhealthy,
|
||||
_ => HealthStatus.Unknown
|
||||
};
|
||||
|
||||
var details = new Dictionary<string, object>();
|
||||
if (!string.IsNullOrEmpty(response.DetailsJson))
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, object>>(
|
||||
response.DetailsJson, _jsonOptions);
|
||||
if (parsed != null)
|
||||
{
|
||||
foreach (var kvp in parsed)
|
||||
{
|
||||
details[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
status,
|
||||
response.Message,
|
||||
TimeSpan.FromMilliseconds(response.DurationMs),
|
||||
details);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_channel?.Dispose();
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected to plugin");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAddress(string socketPath)
|
||||
{
|
||||
// gRPC requires an http:// address even for Unix sockets
|
||||
// The actual connection is handled by the custom handler
|
||||
return "http://localhost";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Communication;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for gRPC communication with sandboxed plugins.
|
||||
/// </summary>
|
||||
public interface IGrpcPluginBridge : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Connect to the plugin host at the specified address.
|
||||
/// </summary>
|
||||
/// <param name="address">Socket path or address to connect to.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ConnectAsync(string address, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from the plugin host.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task DisconnectAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the plugin with its manifest.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Plugin manifest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task InitializePluginAsync(PluginManifest manifest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Request graceful shutdown of the plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ShutdownPluginAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform a health check on the plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Invoke an operation on the plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Operation result.</returns>
|
||||
Task<T> InvokeAsync<T>(string operationName, object? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Invoke a streaming operation on the plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">Event type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of events.</returns>
|
||||
IAsyncEnumerable<TEvent> InvokeStreamingAsync<TEvent>(string operationName, object? parameters, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Whether currently connected to the plugin.
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package stellaops.plugin.bridge;
|
||||
|
||||
option csharp_namespace = "StellaOps.Plugin.Sandbox.Communication";
|
||||
|
||||
// Service for communicating with sandboxed plugins.
|
||||
service PluginBridge {
|
||||
// Initialize the plugin with its manifest and configuration.
|
||||
rpc Initialize(InitializeRequest) returns (InitializeResponse);
|
||||
|
||||
// Request graceful shutdown of the plugin.
|
||||
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
|
||||
|
||||
// Perform a health check on the plugin.
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
|
||||
// Invoke a plugin operation.
|
||||
rpc Invoke(InvokeRequest) returns (InvokeResponse);
|
||||
|
||||
// Invoke a streaming plugin operation.
|
||||
rpc InvokeStreaming(InvokeRequest) returns (stream StreamingEvent);
|
||||
|
||||
// Stream log entries from the plugin.
|
||||
rpc StreamLogs(LogStreamRequest) returns (stream LogEntry);
|
||||
|
||||
// Get plugin capabilities.
|
||||
rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse);
|
||||
}
|
||||
|
||||
// Request to initialize a plugin.
|
||||
message InitializeRequest {
|
||||
// JSON-serialized plugin manifest.
|
||||
string manifest_json = 1;
|
||||
|
||||
// JSON-serialized plugin configuration.
|
||||
string config_json = 2;
|
||||
|
||||
// Sandbox ID for correlation.
|
||||
string sandbox_id = 3;
|
||||
}
|
||||
|
||||
// Response from plugin initialization.
|
||||
message InitializeResponse {
|
||||
// Whether initialization succeeded.
|
||||
bool success = 1;
|
||||
|
||||
// Error message if initialization failed.
|
||||
string error = 2;
|
||||
|
||||
// Plugin version.
|
||||
string version = 3;
|
||||
|
||||
// Capabilities provided by the plugin.
|
||||
repeated string capabilities = 4;
|
||||
}
|
||||
|
||||
// Request for graceful shutdown.
|
||||
message ShutdownRequest {
|
||||
// Maximum time in milliseconds to wait for shutdown.
|
||||
int32 timeout_ms = 1;
|
||||
|
||||
// Reason for shutdown.
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// Response from shutdown request.
|
||||
message ShutdownResponse {
|
||||
// Whether shutdown was successful.
|
||||
bool success = 1;
|
||||
|
||||
// Error message if shutdown failed.
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
// Request for health check.
|
||||
message HealthCheckRequest {
|
||||
// Whether to include detailed diagnostics.
|
||||
bool include_details = 1;
|
||||
}
|
||||
|
||||
// Response from health check.
|
||||
message HealthCheckResponse {
|
||||
// Health status: healthy, degraded, unhealthy.
|
||||
string status = 1;
|
||||
|
||||
// Optional message describing the status.
|
||||
string message = 2;
|
||||
|
||||
// Duration of the health check in milliseconds.
|
||||
int32 duration_ms = 3;
|
||||
|
||||
// JSON-serialized additional details.
|
||||
string details_json = 4;
|
||||
}
|
||||
|
||||
// Request to invoke a plugin operation.
|
||||
message InvokeRequest {
|
||||
// Name of the operation to invoke.
|
||||
string operation = 1;
|
||||
|
||||
// JSON-serialized operation parameters.
|
||||
string parameters_json = 2;
|
||||
|
||||
// Maximum time in milliseconds for the operation.
|
||||
int32 timeout_ms = 3;
|
||||
|
||||
// Correlation ID for tracking.
|
||||
string correlation_id = 4;
|
||||
}
|
||||
|
||||
// Response from an operation invocation.
|
||||
message InvokeResponse {
|
||||
// Whether the operation succeeded.
|
||||
bool success = 1;
|
||||
|
||||
// JSON-serialized result.
|
||||
string result_json = 2;
|
||||
|
||||
// Error message if operation failed.
|
||||
string error = 3;
|
||||
|
||||
// Error code if operation failed.
|
||||
string error_code = 4;
|
||||
|
||||
// Execution time in milliseconds.
|
||||
int32 execution_time_ms = 5;
|
||||
}
|
||||
|
||||
// Event from a streaming operation.
|
||||
message StreamingEvent {
|
||||
// Type of event.
|
||||
string event_type = 1;
|
||||
|
||||
// JSON-serialized event payload.
|
||||
string payload_json = 2;
|
||||
|
||||
// Unix timestamp in milliseconds.
|
||||
int64 timestamp_unix_ms = 3;
|
||||
|
||||
// Sequence number for ordering.
|
||||
int64 sequence = 4;
|
||||
}
|
||||
|
||||
// Request to stream logs.
|
||||
message LogStreamRequest {
|
||||
// Minimum log level to stream.
|
||||
string min_level = 1;
|
||||
|
||||
// Maximum number of historical entries to include.
|
||||
int32 history_count = 2;
|
||||
}
|
||||
|
||||
// A log entry from the plugin.
|
||||
message LogEntry {
|
||||
// Unix timestamp in milliseconds.
|
||||
int64 timestamp_unix_ms = 1;
|
||||
|
||||
// Log level.
|
||||
string level = 2;
|
||||
|
||||
// Log message.
|
||||
string message = 3;
|
||||
|
||||
// JSON-serialized structured properties.
|
||||
string properties_json = 4;
|
||||
|
||||
// Logger category/name.
|
||||
string category = 5;
|
||||
|
||||
// Exception details if present.
|
||||
string exception = 6;
|
||||
}
|
||||
|
||||
// Request to get plugin capabilities.
|
||||
message GetCapabilitiesRequest {}
|
||||
|
||||
// Response with plugin capabilities.
|
||||
message GetCapabilitiesResponse {
|
||||
// List of capability types provided.
|
||||
repeated CapabilityInfo capabilities = 1;
|
||||
}
|
||||
|
||||
// Information about a single capability.
|
||||
message CapabilityInfo {
|
||||
// Capability type.
|
||||
string type = 1;
|
||||
|
||||
// Capability ID.
|
||||
string id = 2;
|
||||
|
||||
// JSON-serialized capability configuration schema.
|
||||
string config_schema_json = 3;
|
||||
|
||||
// JSON-serialized capability input schema.
|
||||
string input_schema_json = 4;
|
||||
|
||||
// JSON-serialized capability output schema.
|
||||
string output_schema_json = 5;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering sandbox services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional configuration for plugin process manager.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandbox(
|
||||
this IServiceCollection services,
|
||||
Action<PluginProcessManagerOptions>? configureOptions = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configureOptions != null)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.Configure<PluginProcessManagerOptions>(_ => { });
|
||||
}
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register process manager
|
||||
services.AddSingleton<IPluginProcessManager, PluginProcessManager>();
|
||||
|
||||
// Register network policy enforcer
|
||||
services.AddSingleton<INetworkPolicyEnforcer, NetworkPolicyEnforcer>();
|
||||
|
||||
// Register resource limiter (platform-specific)
|
||||
services.AddSingleton<IResourceLimiter>(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WindowsResourceLimiter(
|
||||
loggerFactory.CreateLogger<WindowsResourceLimiter>());
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LinuxResourceLimiter(
|
||||
loggerFactory.CreateLogger<LinuxResourceLimiter>());
|
||||
}
|
||||
});
|
||||
|
||||
// Register sandbox factory
|
||||
services.AddSingleton<ISandboxFactory, SandboxFactory>();
|
||||
|
||||
// Register gRPC bridge as transient (one per sandbox)
|
||||
services.AddTransient<IGrpcPluginBridge, GrpcPluginBridge>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services with custom resource limiter.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResourceLimiter">The resource limiter type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandbox<TResourceLimiter>(
|
||||
this IServiceCollection services)
|
||||
where TResourceLimiter : class, IResourceLimiter
|
||||
{
|
||||
services.AddPluginSandbox();
|
||||
|
||||
// Replace resource limiter registration
|
||||
services.AddSingleton<IResourceLimiter, TResourceLimiter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds plugin sandbox services with custom network policy enforcer.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNetworkEnforcer">The network policy enforcer type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPluginSandboxWithNetworkEnforcer<TNetworkEnforcer>(
|
||||
this IServiceCollection services)
|
||||
where TNetworkEnforcer : class, INetworkPolicyEnforcer
|
||||
{
|
||||
services.AddPluginSandbox();
|
||||
|
||||
// Replace network enforcer registration
|
||||
services.AddSingleton<INetworkPolicyEnforcer, TNetworkEnforcer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Filesystem;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for filesystem access control in sandboxes.
|
||||
/// </summary>
|
||||
public interface IFilesystemPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Check if read access is allowed to a path.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if read access is allowed.</returns>
|
||||
bool CanRead(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Check if write access is allowed to a path.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if write access is allowed.</returns>
|
||||
bool CanWrite(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a path is blocked.
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check.</param>
|
||||
/// <returns>True if the path is blocked.</returns>
|
||||
bool IsBlocked(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Get the sandboxed working directory.
|
||||
/// </summary>
|
||||
/// <returns>Path to the sandbox working directory.</returns>
|
||||
string GetWorkingDirectory();
|
||||
|
||||
/// <summary>
|
||||
/// Get the current write usage in bytes.
|
||||
/// </summary>
|
||||
/// <returns>Total bytes written.</returns>
|
||||
long GetWriteUsage();
|
||||
|
||||
/// <summary>
|
||||
/// Check if write quota is exceeded.
|
||||
/// </summary>
|
||||
/// <returns>True if write quota is exceeded.</returns>
|
||||
bool IsWriteQuotaExceeded();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of filesystem policy enforcement.
|
||||
/// </summary>
|
||||
public sealed class SandboxedFilesystem : IFilesystemPolicy
|
||||
{
|
||||
private readonly FilesystemPolicy _policy;
|
||||
private readonly string _workingDirectory;
|
||||
private long _bytesWritten;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sandboxed filesystem policy.
|
||||
/// </summary>
|
||||
/// <param name="policy">Policy configuration.</param>
|
||||
/// <param name="workingDirectory">Working directory for the sandbox.</param>
|
||||
public SandboxedFilesystem(FilesystemPolicy policy, string workingDirectory)
|
||||
{
|
||||
_policy = policy;
|
||||
_workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRead(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
if (IsBlocked(normalizedPath))
|
||||
return false;
|
||||
|
||||
// Allow reads from working directory
|
||||
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Allow reads from explicitly allowed read-only paths
|
||||
foreach (var readOnlyPath in _policy.ReadOnlyPaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(readOnlyPath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow reads from writable paths
|
||||
foreach (var writablePath in _policy.WritablePaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanWrite(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
if (IsBlocked(normalizedPath))
|
||||
return false;
|
||||
|
||||
if (IsWriteQuotaExceeded())
|
||||
return false;
|
||||
|
||||
// Allow writes to working directory
|
||||
if (normalizedPath.StartsWith(_workingDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Allow writes to explicitly writable paths
|
||||
foreach (var writablePath in _policy.WritablePaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(writablePath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsBlocked(string path)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
foreach (var blockedPath in _policy.BlockedPaths)
|
||||
{
|
||||
if (normalizedPath.StartsWith(Path.GetFullPath(blockedPath), StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetWorkingDirectory() => _workingDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public long GetWriteUsage()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsWriteQuotaExceeded()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bytesWritten >= _policy.MaxWriteBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bytes written.
|
||||
/// </summary>
|
||||
/// <param name="bytes">Number of bytes written.</param>
|
||||
public void RecordWrite(long bytes)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_bytesWritten += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Sandbox/ISandbox.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Sandbox/ISandbox.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Provides isolated execution environment for untrusted plugins.
|
||||
/// </summary>
|
||||
public interface ISandbox : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this sandbox instance.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current sandbox state.
|
||||
/// </summary>
|
||||
SandboxState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current resource usage statistics.
|
||||
/// </summary>
|
||||
ResourceUsage CurrentUsage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Start the sandbox and load the plugin.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Plugin manifest to load.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StartAsync(PluginManifest manifest, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop the sandbox gracefully within the specified timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(TimeSpan timeout, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Execute an operation in the sandbox and return the result.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Result type.</typeparam>
|
||||
/// <param name="operationName">Name of the operation to execute.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the operation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
Task<T> ExecuteAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Execute a streaming operation in the sandbox.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEvent">Event type for the stream.</typeparam>
|
||||
/// <param name="operationName">Name of the operation to execute.</param>
|
||||
/// <param name="parameters">Operation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of events.</returns>
|
||||
IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Perform health check on the sandboxed plugin.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when sandbox state changes.
|
||||
/// </summary>
|
||||
event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when resource limits are approached.
|
||||
/// </summary>
|
||||
event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
|
||||
}
|
||||
27
src/Plugin/StellaOps.Plugin.Sandbox/ISandboxFactory.cs
Normal file
27
src/Plugin/StellaOps.Plugin.Sandbox/ISandboxFactory.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating sandbox instances.
|
||||
/// </summary>
|
||||
public interface ISandboxFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new sandbox with the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Sandbox configuration.</param>
|
||||
/// <returns>A new sandbox instance.</returns>
|
||||
ISandbox Create(SandboxConfiguration configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new sandbox with default configuration.
|
||||
/// </summary>
|
||||
/// <returns>A new sandbox instance with default settings.</returns>
|
||||
ISandbox CreateDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Create a sandbox with configuration appropriate for the given trust level.
|
||||
/// </summary>
|
||||
/// <param name="trustLevel">Trust level of the plugin.</param>
|
||||
/// <returns>A sandbox configured for the trust level.</returns>
|
||||
ISandbox CreateForTrustLevel(Abstractions.PluginTrustLevel trustLevel);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for enforcing network policies on sandboxes.
|
||||
/// </summary>
|
||||
public interface INetworkPolicyEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply a network policy to a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="policy">Network policy to apply.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remove network policy from a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RemovePolicyAsync(string sandboxId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a connection is allowed by the policy.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <param name="host">Target host.</param>
|
||||
/// <param name="port">Target port.</param>
|
||||
/// <returns>True if the connection is allowed.</returns>
|
||||
bool IsAllowed(string sandboxId, string host, int port);
|
||||
|
||||
/// <summary>
|
||||
/// Get the current policy for a sandbox.
|
||||
/// </summary>
|
||||
/// <param name="sandboxId">Sandbox identifier.</param>
|
||||
/// <returns>The active policy, or null if not found.</returns>
|
||||
NetworkPolicy? GetPolicy(string sandboxId);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Network;
|
||||
|
||||
/// <summary>
|
||||
/// Network policy enforcer implementation.
|
||||
/// Uses iptables on Linux, Windows Firewall on Windows.
|
||||
/// </summary>
|
||||
public sealed class NetworkPolicyEnforcer : INetworkPolicyEnforcer
|
||||
{
|
||||
private readonly ILogger<NetworkPolicyEnforcer> _logger;
|
||||
private readonly ConcurrentDictionary<string, NetworkPolicy> _policies = new();
|
||||
private readonly ConcurrentDictionary<string, List<string>> _ruleIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new network policy enforcer.
|
||||
/// </summary>
|
||||
public NetworkPolicyEnforcer(ILogger<NetworkPolicyEnforcer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ApplyPolicyAsync(string sandboxId, NetworkPolicy policy, CancellationToken ct)
|
||||
{
|
||||
_policies[sandboxId] = policy;
|
||||
var ruleIds = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
await ApplyLinuxPolicyAsync(sandboxId, policy, ruleIds, ct);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
await ApplyWindowsPolicyAsync(sandboxId, policy, ruleIds, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Network policy enforcement not supported on this platform for sandbox {SandboxId}",
|
||||
sandboxId);
|
||||
}
|
||||
|
||||
_ruleIds[sandboxId] = ruleIds;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Applied network policy to sandbox {SandboxId}: AllowedHosts={AllowedHosts}, BlockedPorts={BlockedPorts}",
|
||||
sandboxId,
|
||||
policy.AllowedHosts.Count,
|
||||
policy.BlockedPorts.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply network policy to sandbox {SandboxId}", sandboxId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemovePolicyAsync(string sandboxId, CancellationToken ct)
|
||||
{
|
||||
_policies.TryRemove(sandboxId, out _);
|
||||
|
||||
if (!_ruleIds.TryRemove(sandboxId, out var ruleIds))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
await RemoveLinuxRulesAsync(ruleIds, ct);
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
await RemoveWindowsRulesAsync(ruleIds, ct);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Removed network policy for sandbox {SandboxId}", sandboxId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove network policy for sandbox {SandboxId}", sandboxId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAllowed(string sandboxId, string host, int port)
|
||||
{
|
||||
if (!_policies.TryGetValue(sandboxId, out var policy))
|
||||
return true; // No policy = allow all
|
||||
|
||||
// Check blocked ports
|
||||
if (policy.BlockedPorts.Contains(port))
|
||||
return false;
|
||||
|
||||
// Check if network is disabled
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
return false;
|
||||
|
||||
// If no allowed hosts specified, allow all (except blocked)
|
||||
if (policy.AllowedHosts.Count == 0)
|
||||
return true;
|
||||
|
||||
// Check allowed hosts
|
||||
foreach (var pattern in policy.AllowedHosts)
|
||||
{
|
||||
if (MatchesHostPattern(host, pattern))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public NetworkPolicy? GetPolicy(string sandboxId)
|
||||
{
|
||||
return _policies.GetValueOrDefault(sandboxId);
|
||||
}
|
||||
|
||||
private async Task ApplyLinuxPolicyAsync(
|
||||
string sandboxId,
|
||||
NetworkPolicy policy,
|
||||
List<string> ruleIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var chainName = $"STELLAOPS_{SanitizeForChain(sandboxId)}";
|
||||
|
||||
// Create custom chain
|
||||
await RunCommandAsync("iptables", $"-N {chainName}", ct, ignoreError: true);
|
||||
ruleIds.Add($"chain:{chainName}");
|
||||
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
{
|
||||
// Block all outbound traffic
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j DROP",
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block specific ports
|
||||
foreach (var port in policy.BlockedPorts)
|
||||
{
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -p tcp --dport {port} -j DROP",
|
||||
ct);
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -p udp --dport {port} -j DROP",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Allow specific hosts (if specified)
|
||||
if (policy.AllowedHosts.Count > 0)
|
||||
{
|
||||
// Default drop, then allow specific
|
||||
foreach (var host in policy.AllowedHosts)
|
||||
{
|
||||
if (TryResolveHost(host, out var ip))
|
||||
{
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -d {ip} -j ACCEPT",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j DROP",
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Allow all (except blocked ports above)
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A {chainName} -j ACCEPT",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Add jump to custom chain from OUTPUT
|
||||
await RunCommandAsync("iptables",
|
||||
$"-A OUTPUT -j {chainName}",
|
||||
ct);
|
||||
ruleIds.Add($"jump:{chainName}");
|
||||
}
|
||||
|
||||
private async Task RemoveLinuxRulesAsync(List<string> ruleIds, CancellationToken ct)
|
||||
{
|
||||
foreach (var ruleId in ruleIds.AsEnumerable().Reverse())
|
||||
{
|
||||
if (ruleId.StartsWith("jump:", StringComparison.Ordinal))
|
||||
{
|
||||
var chainName = ruleId[5..];
|
||||
await RunCommandAsync("iptables",
|
||||
$"-D OUTPUT -j {chainName}",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
else if (ruleId.StartsWith("chain:", StringComparison.Ordinal))
|
||||
{
|
||||
var chainName = ruleId[6..];
|
||||
await RunCommandAsync("iptables",
|
||||
$"-F {chainName}",
|
||||
ct, ignoreError: true);
|
||||
await RunCommandAsync("iptables",
|
||||
$"-X {chainName}",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyWindowsPolicyAsync(
|
||||
string sandboxId,
|
||||
NetworkPolicy policy,
|
||||
List<string> ruleIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rulePrefix = $"StellaOps_Sandbox_{SanitizeForRule(sandboxId)}";
|
||||
|
||||
if (!policy.AllowAllHosts && policy.AllowedHosts.Count == 0)
|
||||
{
|
||||
// Block all outbound
|
||||
var ruleName = $"{rulePrefix}_BlockAll";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block specific ports
|
||||
var portIndex = 0;
|
||||
foreach (var port in policy.BlockedPorts)
|
||||
{
|
||||
var ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=tcp remoteport={port}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
|
||||
ruleName = $"{rulePrefix}_BlockPort{portIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=block protocol=udp remoteport={port}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
}
|
||||
|
||||
// Allow specific hosts (if specified)
|
||||
if (policy.AllowedHosts.Count > 0)
|
||||
{
|
||||
var hostIndex = 0;
|
||||
foreach (var host in policy.AllowedHosts)
|
||||
{
|
||||
if (TryResolveHost(host, out var ip))
|
||||
{
|
||||
var ruleName = $"{rulePrefix}_AllowHost{hostIndex++}";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{ruleName}\" dir=out action=allow remoteip={ip}",
|
||||
ct);
|
||||
ruleIds.Add(ruleName);
|
||||
}
|
||||
}
|
||||
|
||||
// Block all other
|
||||
var blockRuleName = $"{rulePrefix}_BlockOther";
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall add rule name=\"{blockRuleName}\" dir=out action=block",
|
||||
ct);
|
||||
ruleIds.Add(blockRuleName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveWindowsRulesAsync(List<string> ruleIds, CancellationToken ct)
|
||||
{
|
||||
foreach (var ruleName in ruleIds)
|
||||
{
|
||||
await RunCommandAsync("netsh",
|
||||
$"advfirewall firewall delete rule name=\"{ruleName}\"",
|
||||
ct, ignoreError: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(
|
||||
string command,
|
||||
string arguments,
|
||||
CancellationToken ct,
|
||||
bool ignoreError = false)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = arguments,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = startInfo };
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
if (process.ExitCode != 0 && !ignoreError)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync(ct);
|
||||
_logger.LogWarning(
|
||||
"Command '{Command} {Arguments}' failed with exit code {ExitCode}: {Error}",
|
||||
command, arguments, process.ExitCode, error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ignoreError)
|
||||
{
|
||||
_logger.LogDebug(ex, "Command '{Command} {Arguments}' failed (ignored)", command, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesHostPattern(string host, string pattern)
|
||||
{
|
||||
// Simple wildcard matching
|
||||
if (pattern == "*")
|
||||
return true;
|
||||
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern[1..];
|
||||
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryResolveHost(string host, out string ip)
|
||||
{
|
||||
ip = string.Empty;
|
||||
|
||||
// Check if already an IP
|
||||
if (IPAddress.TryParse(host, out var address))
|
||||
{
|
||||
ip = address.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip wildcards
|
||||
if (host.Contains('*'))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = Dns.GetHostAddresses(host);
|
||||
if (addresses.Length > 0)
|
||||
{
|
||||
ip = addresses[0].ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DNS resolution failed
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string SanitizeForChain(string input)
|
||||
{
|
||||
return Regex.Replace(input, "[^a-zA-Z0-9_]", "_")[..Math.Min(input.Length, 20)];
|
||||
}
|
||||
|
||||
private static string SanitizeForRule(string input)
|
||||
{
|
||||
return Regex.Replace(input, "[^a-zA-Z0-9_-]", "_");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Process;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing plugin host processes.
|
||||
/// </summary>
|
||||
public interface IPluginProcessManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Start a new plugin host process.
|
||||
/// </summary>
|
||||
/// <param name="request">Process start request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The started process.</returns>
|
||||
Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stop a plugin host process gracefully.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to stop.</param>
|
||||
/// <param name="timeout">Maximum time to wait for graceful shutdown.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Kill a plugin host process immediately.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to kill.</param>
|
||||
Task KillAsync(SystemProcess process);
|
||||
|
||||
/// <summary>
|
||||
/// Get the path to the plugin host executable.
|
||||
/// </summary>
|
||||
/// <returns>Path to the executable.</returns>
|
||||
string GetHostExecutablePath();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start a plugin host process.
|
||||
/// </summary>
|
||||
public sealed record ProcessStartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the plugin assembly.
|
||||
/// </summary>
|
||||
public required string PluginAssemblyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin entry point type name.
|
||||
/// </summary>
|
||||
public string? EntryPoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Working directory for the process.
|
||||
/// </summary>
|
||||
public required string WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Socket path for IPC communication.
|
||||
/// </summary>
|
||||
public required string SocketPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resource configuration to apply.
|
||||
/// </summary>
|
||||
public required ResourceConfiguration ResourceConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables to set.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Arguments to pass to the host process.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Arguments { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Process;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the plugin process manager.
|
||||
/// </summary>
|
||||
public sealed class PluginProcessManagerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the plugin host executable.
|
||||
/// If not specified, will look for StellaOps.Plugin.Host in the application directory.
|
||||
/// </summary>
|
||||
public string? HostExecutablePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for graceful shutdown.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultShutdownTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin process manager implementation.
|
||||
/// </summary>
|
||||
public sealed class PluginProcessManager : IPluginProcessManager
|
||||
{
|
||||
private readonly PluginProcessManagerOptions _options;
|
||||
private readonly ILogger<PluginProcessManager> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin process manager.
|
||||
/// </summary>
|
||||
public PluginProcessManager(
|
||||
IOptions<PluginProcessManagerOptions> options,
|
||||
ILogger<PluginProcessManager> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SystemProcess> StartAsync(ProcessStartRequest request, CancellationToken ct)
|
||||
{
|
||||
var hostPath = GetHostExecutablePath();
|
||||
|
||||
if (!File.Exists(hostPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin host executable not found at {hostPath}");
|
||||
}
|
||||
|
||||
var arguments = BuildArguments(request);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = request.WorkingDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true
|
||||
};
|
||||
|
||||
// Set environment variables
|
||||
foreach (var kvp in request.EnvironmentVariables)
|
||||
{
|
||||
startInfo.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Add standard sandbox environment variables
|
||||
startInfo.Environment["STELLAOPS_SANDBOX_MODE"] = "true";
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_SOCKET"] = request.SocketPath;
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_ASSEMBLY"] = request.PluginAssemblyPath;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.EntryPoint))
|
||||
{
|
||||
startInfo.Environment["STELLAOPS_PLUGIN_ENTRYPOINT"] = request.EntryPoint;
|
||||
}
|
||||
|
||||
var process = new SystemProcess { StartInfo = startInfo };
|
||||
|
||||
// Set up output handling
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_logger.LogDebug("[Plugin:{Pid}] {Output}", process.Id, e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_logger.LogWarning("[Plugin:{Pid}] {Error}", process.Id, e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start plugin host process");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Started plugin host process {Pid} for assembly {Assembly}",
|
||||
process.Id,
|
||||
Path.GetFileName(request.PluginAssemblyPath));
|
||||
|
||||
return Task.FromResult(process);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start plugin host process");
|
||||
process.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(SystemProcess process, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (process.HasExited)
|
||||
{
|
||||
_logger.LogDebug("Process {Pid} already exited", process.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try graceful shutdown first via stdin
|
||||
if (!process.StandardInput.BaseStream.CanWrite)
|
||||
{
|
||||
_logger.LogDebug("Cannot write to stdin for process {Pid}", process.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await process.StandardInput.WriteLineAsync("SHUTDOWN");
|
||||
await process.StandardInput.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// Wait for graceful exit
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
_logger.LogDebug("Process {Pid} exited gracefully with code {ExitCode}",
|
||||
process.Id, process.ExitCode);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Process {Pid} did not exit gracefully within timeout", process.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during graceful shutdown of process {Pid}", process.Id);
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
await KillAsync(process);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task KillAsync(SystemProcess process)
|
||||
{
|
||||
if (process.HasExited)
|
||||
return Task.CompletedTask;
|
||||
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
_logger.LogWarning("Killed process {Pid}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to kill process {Pid}", process.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetHostExecutablePath()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_options.HostExecutablePath))
|
||||
{
|
||||
return _options.HostExecutablePath;
|
||||
}
|
||||
|
||||
var appDir = AppContext.BaseDirectory;
|
||||
var hostName = OperatingSystem.IsWindows()
|
||||
? "StellaOps.Plugin.Host.exe"
|
||||
: "StellaOps.Plugin.Host";
|
||||
|
||||
var hostPath = Path.Combine(appDir, hostName);
|
||||
|
||||
// Also check in tools subdirectory
|
||||
if (!File.Exists(hostPath))
|
||||
{
|
||||
hostPath = Path.Combine(appDir, "tools", hostName);
|
||||
}
|
||||
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
private static string BuildArguments(ProcessStartRequest request)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"--assembly", QuoteArgument(request.PluginAssemblyPath),
|
||||
"--socket", QuoteArgument(request.SocketPath),
|
||||
"--workdir", QuoteArgument(request.WorkingDirectory)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(request.EntryPoint))
|
||||
{
|
||||
args.Add("--entrypoint");
|
||||
args.Add(QuoteArgument(request.EntryPoint));
|
||||
}
|
||||
|
||||
// Add custom arguments
|
||||
foreach (var arg in request.Arguments)
|
||||
{
|
||||
args.Add(arg);
|
||||
}
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string arg)
|
||||
{
|
||||
if (arg.Contains(' ') || arg.Contains('"'))
|
||||
{
|
||||
return $"\"{arg.Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
473
src/Plugin/StellaOps.Plugin.Sandbox/ProcessSandbox.cs
Normal file
473
src/Plugin/StellaOps.Plugin.Sandbox/ProcessSandbox.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Manifest;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Filesystem;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Process-based sandbox implementation for untrusted plugins.
|
||||
/// </summary>
|
||||
public sealed class ProcessSandbox : ISandbox
|
||||
{
|
||||
private readonly SandboxConfiguration _config;
|
||||
private readonly IPluginProcessManager _processManager;
|
||||
private readonly IGrpcPluginBridge _bridge;
|
||||
private readonly IResourceLimiter _resourceLimiter;
|
||||
private readonly INetworkPolicyEnforcer _networkEnforcer;
|
||||
private readonly ILogger<ProcessSandbox> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private SystemProcess? _process;
|
||||
private SandboxState _state = SandboxState.Created;
|
||||
private ResourceUsage _currentUsage = ResourceUsage.Empty;
|
||||
private CancellationTokenSource? _monitoringCts;
|
||||
private Task? _monitoringTask;
|
||||
private string? _workingDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public SandboxState State => _state;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceUsage CurrentUsage => _currentUsage;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SandboxStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<ResourceWarningEventArgs>? ResourceWarning;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new process sandbox.
|
||||
/// </summary>
|
||||
public ProcessSandbox(
|
||||
string id,
|
||||
SandboxConfiguration config,
|
||||
IPluginProcessManager processManager,
|
||||
IGrpcPluginBridge bridge,
|
||||
IResourceLimiter resourceLimiter,
|
||||
INetworkPolicyEnforcer networkEnforcer,
|
||||
ILogger<ProcessSandbox> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
Id = id;
|
||||
_config = config;
|
||||
_processManager = processManager;
|
||||
_bridge = bridge;
|
||||
_resourceLimiter = resourceLimiter;
|
||||
_networkEnforcer = networkEnforcer;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(PluginManifest manifest, CancellationToken ct)
|
||||
{
|
||||
TransitionState(SandboxState.Starting);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Create isolated working directory
|
||||
_workingDirectory = PrepareWorkingDirectory(manifest);
|
||||
|
||||
// 2. Configure resource limits
|
||||
var resourceConfig = _resourceLimiter.CreateConfiguration(_config.ResourceLimits);
|
||||
|
||||
// 3. Configure network policy
|
||||
await _networkEnforcer.ApplyPolicyAsync(Id, _config.NetworkPolicy, ct);
|
||||
|
||||
// 4. Start the plugin host process
|
||||
var socketPath = GetSocketPath();
|
||||
_process = await _processManager.StartAsync(new ProcessStartRequest
|
||||
{
|
||||
PluginAssemblyPath = manifest.AssemblyPath!,
|
||||
EntryPoint = manifest.EntryPoint,
|
||||
WorkingDirectory = _workingDirectory,
|
||||
SocketPath = socketPath,
|
||||
ResourceConfiguration = resourceConfig,
|
||||
EnvironmentVariables = _config.EnvironmentVariables
|
||||
}, ct);
|
||||
|
||||
// 5. Apply resource limits to the process
|
||||
await _resourceLimiter.ApplyLimitsAsync(_process, resourceConfig, ct);
|
||||
|
||||
// 6. Wait for the process to be ready and connect
|
||||
await WaitForReadyAsync(socketPath, ct);
|
||||
|
||||
// 7. Initialize the plugin
|
||||
await _bridge.InitializePluginAsync(manifest, ct);
|
||||
|
||||
// 8. Start resource monitoring
|
||||
StartResourceMonitoring();
|
||||
|
||||
TransitionState(SandboxState.Running);
|
||||
|
||||
_logger.LogInformation("Sandbox {Id} started for plugin {PluginId}",
|
||||
Id, manifest.Info.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to start sandbox {Id}", Id);
|
||||
TransitionState(SandboxState.Failed, ex.Message);
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
if (_state is SandboxState.Stopped or SandboxState.Failed or SandboxState.Killed)
|
||||
return;
|
||||
|
||||
TransitionState(SandboxState.Stopping);
|
||||
|
||||
try
|
||||
{
|
||||
// Stop monitoring
|
||||
_monitoringCts?.Cancel();
|
||||
if (_monitoringTask != null)
|
||||
{
|
||||
try { await _monitoringTask; } catch { /* Ignore */ }
|
||||
}
|
||||
|
||||
// 1. Signal graceful shutdown via gRPC
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
if (_bridge.IsConnected)
|
||||
{
|
||||
await _bridge.ShutdownPluginAsync(timeoutCts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Sandbox {Id} did not shutdown gracefully, killing", Id);
|
||||
}
|
||||
|
||||
// 2. Disconnect bridge
|
||||
await _bridge.DisconnectAsync(ct);
|
||||
|
||||
// 3. Stop the process
|
||||
if (_process != null)
|
||||
{
|
||||
await _processManager.StopAsync(_process, timeout, ct);
|
||||
}
|
||||
|
||||
// 4. Cleanup resources
|
||||
await CleanupAsync();
|
||||
|
||||
TransitionState(SandboxState.Stopped);
|
||||
|
||||
_logger.LogInformation("Sandbox {Id} stopped", Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping sandbox {Id}", Id);
|
||||
TransitionState(SandboxState.Failed, ex.Message);
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<T> ExecuteAsync<T>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
return await _bridge.InvokeAsync<T>(operationName, parameters, timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Operation '{operationName}' timed out after {timeout}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TEvent> ExecuteStreamingAsync<TEvent>(
|
||||
string operationName,
|
||||
object? parameters,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
EnsureRunning();
|
||||
|
||||
await foreach (var evt in _bridge.InvokeStreamingAsync<TEvent>(operationName, parameters, ct))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
if (_state != SandboxState.Running)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"Sandbox is in state {_state}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_config.Timeouts.HealthCheckTimeout);
|
||||
|
||||
var result = await _bridge.HealthCheckAsync(timeoutCts.Token);
|
||||
|
||||
// Add resource usage to details
|
||||
var details = new Dictionary<string, object>(result.Details ?? new Dictionary<string, object>())
|
||||
{
|
||||
["sandboxId"] = Id,
|
||||
["memoryUsageMb"] = _currentUsage.MemoryUsageMb,
|
||||
["cpuUsagePercent"] = _currentUsage.CpuUsagePercent
|
||||
};
|
||||
|
||||
return result with { Details = details };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_state == SandboxState.Running)
|
||||
{
|
||||
await StopAsync(_config.Timeouts.ShutdownTimeout, CancellationToken.None);
|
||||
}
|
||||
|
||||
_bridge.Dispose();
|
||||
_monitoringCts?.Dispose();
|
||||
}
|
||||
|
||||
private void EnsureRunning()
|
||||
{
|
||||
if (_state != SandboxState.Running)
|
||||
{
|
||||
throw new InvalidOperationException($"Sandbox is not running (state: {_state})");
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionState(SandboxState newState, string? reason = null)
|
||||
{
|
||||
var oldState = _state;
|
||||
_state = newState;
|
||||
|
||||
_logger.LogDebug("Sandbox {Id} state changed: {OldState} -> {NewState} ({Reason})",
|
||||
Id, oldState, newState, reason ?? "N/A");
|
||||
|
||||
StateChanged?.Invoke(this, new SandboxStateChangedEventArgs
|
||||
{
|
||||
OldState = oldState,
|
||||
NewState = newState,
|
||||
Reason = reason
|
||||
});
|
||||
}
|
||||
|
||||
private string PrepareWorkingDirectory(PluginManifest manifest)
|
||||
{
|
||||
var workDir = _config.WorkingDirectory
|
||||
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
|
||||
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, recursive: true);
|
||||
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
// Copy plugin files to sandbox directory
|
||||
if (!string.IsNullOrEmpty(manifest.AssemblyPath))
|
||||
{
|
||||
var pluginDir = Path.GetDirectoryName(manifest.AssemblyPath);
|
||||
if (!string.IsNullOrEmpty(pluginDir) && Directory.Exists(pluginDir))
|
||||
{
|
||||
CopyDirectory(pluginDir, workDir);
|
||||
}
|
||||
}
|
||||
|
||||
return workDir;
|
||||
}
|
||||
|
||||
private async Task CleanupAsync()
|
||||
{
|
||||
// Cleanup network policy
|
||||
try
|
||||
{
|
||||
await _networkEnforcer.RemovePolicyAsync(Id, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup network policy for sandbox {Id}", Id);
|
||||
}
|
||||
|
||||
// Cleanup resource limits
|
||||
if (_process != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _resourceLimiter.RemoveLimitsAsync(_process, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup resource limits for sandbox {Id}", Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup working directory
|
||||
CleanupWorkingDirectory();
|
||||
}
|
||||
|
||||
private void CleanupWorkingDirectory()
|
||||
{
|
||||
var workDir = _workingDirectory
|
||||
?? Path.Combine(Path.GetTempPath(), "stellaops-sandbox", Id);
|
||||
|
||||
if (Directory.Exists(workDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(workDir, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup sandbox directory {WorkDir}", workDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSocketPath()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return $"\\\\.\\pipe\\stellaops-sandbox-{Id}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return Path.Combine(Path.GetTempPath(), $"stellaops-sandbox-{Id}.sock");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForReadyAsync(string socketPath, CancellationToken ct)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(_config.Timeouts.StartupTimeout);
|
||||
|
||||
while (!timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
if (_process?.HasExited == true)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin process exited with code {_process.ExitCode}");
|
||||
}
|
||||
|
||||
// Try to connect
|
||||
try
|
||||
{
|
||||
await _bridge.ConnectAsync(socketPath, timeoutCts.Token);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Not ready yet, wait and retry
|
||||
await Task.Delay(100, timeoutCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException("Plugin process did not become ready in time");
|
||||
}
|
||||
|
||||
private void StartResourceMonitoring()
|
||||
{
|
||||
_monitoringCts = new CancellationTokenSource();
|
||||
_monitoringTask = Task.Run(async () =>
|
||||
{
|
||||
while (!_monitoringCts.Token.IsCancellationRequested && _state == SandboxState.Running)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
_currentUsage = await _resourceLimiter.GetUsageAsync(_process, _monitoringCts.Token);
|
||||
|
||||
// Check thresholds
|
||||
CheckResourceThreshold(ResourceType.Memory,
|
||||
_currentUsage.MemoryUsageMb,
|
||||
_config.ResourceLimits.MaxMemoryMb);
|
||||
|
||||
CheckResourceThreshold(ResourceType.Cpu,
|
||||
_currentUsage.CpuUsagePercent,
|
||||
_config.ResourceLimits.MaxCpuPercent);
|
||||
|
||||
// Check if limits exceeded
|
||||
var limitCheck = await _resourceLimiter.CheckLimitsAsync(
|
||||
_process, _config.ResourceLimits, _monitoringCts.Token);
|
||||
|
||||
if (limitCheck.IsExceeded)
|
||||
{
|
||||
_logger.LogWarning("Sandbox {Id} exceeded resource limit: {Message}",
|
||||
Id, limitCheck.Message);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(1000, _monitoringCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error monitoring resources for sandbox {Id}", Id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void CheckResourceThreshold(ResourceType resource, double current, double max)
|
||||
{
|
||||
if (max <= 0) return;
|
||||
|
||||
var percent = (current / max) * 100;
|
||||
if (percent >= 80)
|
||||
{
|
||||
ResourceWarning?.Invoke(this, new ResourceWarningEventArgs
|
||||
{
|
||||
Resource = resource,
|
||||
CurrentUsagePercent = percent,
|
||||
ThresholdPercent = 80
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string source, string destination)
|
||||
{
|
||||
foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(dir.Replace(source, destination));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
File.Copy(file, file.Replace(source, destination), overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for applying and monitoring resource limits on processes.
|
||||
/// </summary>
|
||||
public interface IResourceLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a resource configuration from the specified limits.
|
||||
/// </summary>
|
||||
/// <param name="limits">Resource limits to configure.</param>
|
||||
/// <returns>Platform-specific resource configuration.</returns>
|
||||
ResourceConfiguration CreateConfiguration(ResourceLimits limits);
|
||||
|
||||
/// <summary>
|
||||
/// Apply resource limits to a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to limit.</param>
|
||||
/// <param name="config">Resource configuration.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ApplyLimitsAsync(SystemProcess process, ResourceConfiguration config, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get current resource usage for a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to monitor.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Current resource usage.</returns>
|
||||
Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remove resource limits from a process.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to unlimit.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the process has exceeded any limits.
|
||||
/// </summary>
|
||||
/// <param name="process">Process to check.</param>
|
||||
/// <param name="limits">Limits to check against.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if any limit is exceeded.</returns>
|
||||
Task<LimitCheckResult> CheckLimitsAsync(SystemProcess process, ResourceLimits limits, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a limit check.
|
||||
/// </summary>
|
||||
public sealed record LimitCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether any limit was exceeded.
|
||||
/// </summary>
|
||||
public required bool IsExceeded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which resource exceeded its limit, if any.
|
||||
/// </summary>
|
||||
public ResourceType? ExceededResource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current value of the exceeded resource.
|
||||
/// </summary>
|
||||
public double? CurrentValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Limit value that was exceeded.
|
||||
/// </summary>
|
||||
public double? LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message describing the exceeded limit.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result indicating no limits were exceeded.
|
||||
/// </summary>
|
||||
public static LimitCheckResult Ok => new() { IsExceeded = false };
|
||||
|
||||
/// <summary>
|
||||
/// Create a result for an exceeded limit.
|
||||
/// </summary>
|
||||
public static LimitCheckResult Exceeded(ResourceType resource, double current, double limit) => new()
|
||||
{
|
||||
IsExceeded = true,
|
||||
ExceededResource = resource,
|
||||
CurrentValue = current,
|
||||
LimitValue = limit,
|
||||
Message = $"{resource} limit exceeded: {current:F2} > {limit:F2}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Linux cgroups v2 resource limiter implementation.
|
||||
/// </summary>
|
||||
public sealed class LinuxResourceLimiter : IResourceLimiter
|
||||
{
|
||||
private const string CgroupBasePath = "/sys/fs/cgroup";
|
||||
private const string StellaOpsCgroupName = "stellaops-sandbox";
|
||||
|
||||
private readonly ILogger<LinuxResourceLimiter> _logger;
|
||||
private readonly Dictionary<int, string> _processCgroups = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Linux resource limiter.
|
||||
/// </summary>
|
||||
public LinuxResourceLimiter(ILogger<LinuxResourceLimiter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
|
||||
{
|
||||
var cpuQuotaUs = limits.MaxCpuPercent > 0
|
||||
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
|
||||
: 0;
|
||||
|
||||
return new ResourceConfiguration
|
||||
{
|
||||
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
|
||||
CpuQuotaUs = cpuQuotaUs,
|
||||
CpuPeriodUs = 100_000,
|
||||
MaxProcesses = limits.MaxProcesses,
|
||||
MaxOpenFiles = limits.MaxOpenFiles
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ApplyLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
_logger.LogWarning("LinuxResourceLimiter called on non-Linux platform");
|
||||
return;
|
||||
}
|
||||
|
||||
var cgroupPath = Path.Combine(CgroupBasePath, $"{StellaOpsCgroupName}-{process.Id}");
|
||||
|
||||
try
|
||||
{
|
||||
// Create cgroup directory
|
||||
if (!Directory.Exists(cgroupPath))
|
||||
{
|
||||
Directory.CreateDirectory(cgroupPath);
|
||||
}
|
||||
|
||||
// Configure memory limit
|
||||
if (config.MemoryLimitBytes > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"memory.max",
|
||||
config.MemoryLimitBytes.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
|
||||
// Also set high watermark for throttling before kill
|
||||
var highMark = (long)(config.MemoryLimitBytes * 0.9);
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"memory.high",
|
||||
highMark.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Configure CPU limit
|
||||
if (config.CpuQuotaUs > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"cpu.max",
|
||||
$"{config.CpuQuotaUs} {config.CpuPeriodUs}",
|
||||
ct);
|
||||
}
|
||||
|
||||
// Configure process limit
|
||||
if (config.MaxProcesses > 0)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"pids.max",
|
||||
config.MaxProcesses.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Add process to cgroup
|
||||
await WriteControlFileAsync(
|
||||
cgroupPath,
|
||||
"cgroup.procs",
|
||||
process.Id.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_processCgroups[process.Id] = cgroupPath;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applied cgroup limits to process {ProcessId}: Memory={MemoryBytes}B, CPU quota={CpuQuotaUs}us",
|
||||
process.Id,
|
||||
config.MemoryLimitBytes,
|
||||
config.CpuQuotaUs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply cgroup limits to process {ProcessId}", process.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return;
|
||||
|
||||
string? cgroupPath;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
|
||||
return;
|
||||
|
||||
_processCgroups.Remove(process.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Move process to root cgroup first (if still running)
|
||||
if (!process.HasExited)
|
||||
{
|
||||
await WriteControlFileAsync(
|
||||
CgroupBasePath,
|
||||
"cgroup.procs",
|
||||
process.Id.ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Remove cgroup directory
|
||||
if (Directory.Exists(cgroupPath))
|
||||
{
|
||||
Directory.Delete(cgroupPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Removed cgroup for process {ProcessId}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup cgroup for process {ProcessId}", process.Id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
|
||||
string? cgroupPath;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_processCgroups.TryGetValue(process.Id, out cgroupPath))
|
||||
{
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read memory usage
|
||||
var memoryCurrentStr = await ReadControlFileAsync(cgroupPath, "memory.current", ct);
|
||||
var memoryCurrent = long.Parse(memoryCurrentStr.Trim(), CultureInfo.InvariantCulture);
|
||||
|
||||
// Calculate CPU percentage (simplified - would need time delta for accurate calculation)
|
||||
var cpuPercent = 0.0; // Requires tracking over time
|
||||
|
||||
return new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = memoryCurrent / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = cpuPercent,
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = GetHandleCount(process),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cgroup stats for process {ProcessId}", process.Id);
|
||||
return GetFallbackUsage(process);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LimitCheckResult> CheckLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceLimits limits,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var usage = await GetUsageAsync(process, ct);
|
||||
|
||||
// Check memory
|
||||
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Memory,
|
||||
usage.MemoryUsageMb,
|
||||
limits.MaxMemoryMb);
|
||||
}
|
||||
|
||||
// Check CPU
|
||||
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu,
|
||||
usage.CpuUsagePercent,
|
||||
limits.MaxCpuPercent);
|
||||
}
|
||||
|
||||
// Check processes
|
||||
if (limits.MaxProcesses > 0 && usage.ProcessCount > limits.MaxProcesses)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu, // Use Cpu as proxy for process limits
|
||||
usage.ProcessCount,
|
||||
limits.MaxProcesses);
|
||||
}
|
||||
|
||||
return LimitCheckResult.Ok;
|
||||
}
|
||||
|
||||
private static async Task WriteControlFileAsync(
|
||||
string cgroupPath,
|
||||
string fileName,
|
||||
string value,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var filePath = Path.Combine(cgroupPath, fileName);
|
||||
await File.WriteAllTextAsync(filePath, value, ct);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadControlFileAsync(
|
||||
string cgroupPath,
|
||||
string fileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var filePath = Path.Combine(cgroupPath, fileName);
|
||||
return await File.ReadAllTextAsync(filePath, ct);
|
||||
}
|
||||
|
||||
private static ResourceUsage GetFallbackUsage(SystemProcess process)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Refresh();
|
||||
return new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = 0, // Can't easily get without tracking
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = GetHandleCount(process),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ResourceUsage.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetHandleCount(SystemProcess process)
|
||||
{
|
||||
try
|
||||
{
|
||||
return process.HandleCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Current resource usage statistics for a sandbox.
|
||||
/// </summary>
|
||||
public sealed record ResourceUsage
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory usage in megabytes.
|
||||
/// </summary>
|
||||
public double MemoryUsageMb { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU usage as a percentage.
|
||||
/// </summary>
|
||||
public double CpuUsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of active processes/threads.
|
||||
/// </summary>
|
||||
public int ProcessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage in bytes.
|
||||
/// </summary>
|
||||
public long DiskUsageBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network bytes received.
|
||||
/// </summary>
|
||||
public long NetworkBytesIn { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network bytes sent.
|
||||
/// </summary>
|
||||
public long NetworkBytesOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of open file handles.
|
||||
/// </summary>
|
||||
public int OpenFileHandles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this usage snapshot was taken.
|
||||
/// </summary>
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty resource usage.
|
||||
/// </summary>
|
||||
public static ResourceUsage Empty => new()
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource configuration for process limits.
|
||||
/// </summary>
|
||||
public sealed record ResourceConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory limit in bytes.
|
||||
/// </summary>
|
||||
public long MemoryLimitBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU quota in microseconds.
|
||||
/// </summary>
|
||||
public long CpuQuotaUs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU period in microseconds.
|
||||
/// </summary>
|
||||
public long CpuPeriodUs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of processes.
|
||||
/// </summary>
|
||||
public int MaxProcesses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of open files.
|
||||
/// </summary>
|
||||
public int MaxOpenFiles { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using SystemProcess = System.Diagnostics.Process;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
/// <summary>
|
||||
/// Windows Job Object resource limiter implementation.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WindowsResourceLimiter : IResourceLimiter, IDisposable
|
||||
{
|
||||
private readonly ILogger<WindowsResourceLimiter> _logger;
|
||||
private readonly Dictionary<int, SafeFileHandle> _jobHandles = new();
|
||||
private readonly object _lock = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Windows resource limiter.
|
||||
/// </summary>
|
||||
public WindowsResourceLimiter(ILogger<WindowsResourceLimiter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResourceConfiguration CreateConfiguration(ResourceLimits limits)
|
||||
{
|
||||
var cpuQuotaUs = limits.MaxCpuPercent > 0
|
||||
? (long)(limits.MaxCpuPercent / 100.0 * 100_000)
|
||||
: 0;
|
||||
|
||||
return new ResourceConfiguration
|
||||
{
|
||||
MemoryLimitBytes = limits.MaxMemoryMb * 1024 * 1024,
|
||||
CpuQuotaUs = cpuQuotaUs,
|
||||
CpuPeriodUs = 100_000,
|
||||
MaxProcesses = limits.MaxProcesses,
|
||||
MaxOpenFiles = limits.MaxOpenFiles
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ApplyLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_logger.LogWarning("WindowsResourceLimiter called on non-Windows platform");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var jobName = $"StellaOps_Sandbox_{process.Id}";
|
||||
|
||||
try
|
||||
{
|
||||
// Create Job Object
|
||||
var jobHandle = NativeMethods.CreateJobObject(IntPtr.Zero, jobName);
|
||||
if (jobHandle.IsInvalid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to create Job Object: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
// Configure limits
|
||||
var extendedInfo = new NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
extendedInfo.BasicLimitInformation.LimitFlags = 0;
|
||||
|
||||
// Memory limit
|
||||
if (config.MemoryLimitBytes > 0)
|
||||
{
|
||||
extendedInfo.ProcessMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
|
||||
extendedInfo.JobMemoryLimit = (UIntPtr)config.MemoryLimitBytes;
|
||||
extendedInfo.BasicLimitInformation.LimitFlags |=
|
||||
NativeMethods.JOB_OBJECT_LIMIT_PROCESS_MEMORY |
|
||||
NativeMethods.JOB_OBJECT_LIMIT_JOB_MEMORY;
|
||||
}
|
||||
|
||||
// Process limit
|
||||
if (config.MaxProcesses > 0)
|
||||
{
|
||||
extendedInfo.BasicLimitInformation.ActiveProcessLimit = (uint)config.MaxProcesses;
|
||||
extendedInfo.BasicLimitInformation.LimitFlags |=
|
||||
NativeMethods.JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
|
||||
}
|
||||
|
||||
// Apply extended limits
|
||||
var extendedInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
|
||||
var success = NativeMethods.SetInformationJobObject(
|
||||
jobHandle,
|
||||
NativeMethods.JobObjectInfoType.ExtendedLimitInformation,
|
||||
ref extendedInfo,
|
||||
extendedInfoSize);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
jobHandle.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to set Job Object limits: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
// Configure CPU rate control (Windows 8+)
|
||||
if (config.CpuQuotaUs > 0)
|
||||
{
|
||||
var cpuRate = (uint)(config.CpuQuotaUs / (double)config.CpuPeriodUs * 10000);
|
||||
var cpuInfo = new NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
||||
{
|
||||
ControlFlags = NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_ENABLE |
|
||||
NativeMethods.JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
|
||||
CpuRate = cpuRate
|
||||
};
|
||||
|
||||
var cpuInfoSize = Marshal.SizeOf<NativeMethods.JOBOBJECT_CPU_RATE_CONTROL_INFORMATION>();
|
||||
NativeMethods.SetInformationJobObject(
|
||||
jobHandle,
|
||||
NativeMethods.JobObjectInfoType.CpuRateControlInformation,
|
||||
ref cpuInfo,
|
||||
cpuInfoSize);
|
||||
// CPU rate control may fail on older Windows versions - non-fatal
|
||||
}
|
||||
|
||||
// Assign process to Job Object
|
||||
success = NativeMethods.AssignProcessToJobObject(jobHandle, process.Handle);
|
||||
if (!success)
|
||||
{
|
||||
jobHandle.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to assign process to Job Object: {Marshal.GetLastWin32Error()}");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_jobHandles[process.Id] = jobHandle;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applied Job Object limits to process {ProcessId}: Memory={MemoryBytes}B",
|
||||
process.Id,
|
||||
config.MemoryLimitBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply Job Object limits to process {ProcessId}", process.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveLimitsAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return Task.CompletedTask;
|
||||
|
||||
SafeFileHandle? jobHandle;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_jobHandles.TryGetValue(process.Id, out jobHandle))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_jobHandles.Remove(process.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Terminate job object (will terminate all processes in job)
|
||||
NativeMethods.TerminateJobObject(jobHandle, 0);
|
||||
jobHandle.Dispose();
|
||||
|
||||
_logger.LogDebug("Removed Job Object for process {ProcessId}", process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup Job Object for process {ProcessId}", process.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ResourceUsage> GetUsageAsync(SystemProcess process, CancellationToken ct)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return Task.FromResult(ResourceUsage.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
process.Refresh();
|
||||
return Task.FromResult(new ResourceUsage
|
||||
{
|
||||
MemoryUsageMb = process.WorkingSet64 / (1024.0 * 1024.0),
|
||||
CpuUsagePercent = GetCpuUsage(process),
|
||||
ProcessCount = process.Threads.Count,
|
||||
OpenFileHandles = process.HandleCount,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.FromResult(ResourceUsage.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LimitCheckResult> CheckLimitsAsync(
|
||||
SystemProcess process,
|
||||
ResourceLimits limits,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var usage = await GetUsageAsync(process, ct);
|
||||
|
||||
// Check memory
|
||||
if (limits.MaxMemoryMb > 0 && usage.MemoryUsageMb > limits.MaxMemoryMb)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Memory,
|
||||
usage.MemoryUsageMb,
|
||||
limits.MaxMemoryMb);
|
||||
}
|
||||
|
||||
// Check CPU
|
||||
if (limits.MaxCpuPercent > 0 && usage.CpuUsagePercent > limits.MaxCpuPercent)
|
||||
{
|
||||
return LimitCheckResult.Exceeded(
|
||||
ResourceType.Cpu,
|
||||
usage.CpuUsagePercent,
|
||||
limits.MaxCpuPercent);
|
||||
}
|
||||
|
||||
return LimitCheckResult.Ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var handle in _jobHandles.Values)
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
_jobHandles.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetCpuUsage(SystemProcess process)
|
||||
{
|
||||
// Simplified CPU usage - accurate measurement requires time-based sampling
|
||||
try
|
||||
{
|
||||
return process.TotalProcessorTime.TotalMilliseconds /
|
||||
(Environment.ProcessorCount * process.TotalProcessorTime.TotalMilliseconds + 1) * 100;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100;
|
||||
public const uint JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200;
|
||||
public const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
|
||||
public const uint JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x00000001;
|
||||
public const uint JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP = 0x00000004;
|
||||
|
||||
public enum JobObjectInfoType
|
||||
{
|
||||
BasicLimitInformation = 2,
|
||||
ExtendedLimitInformation = 9,
|
||||
CpuRateControlInformation = 15
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct IO_COUNTERS
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct JOBOBJECT_CPU_RATE_CONTROL_INFORMATION
|
||||
{
|
||||
public uint ControlFlags;
|
||||
public uint CpuRate;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern SafeFileHandle CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(
|
||||
SafeFileHandle hJob,
|
||||
JobObjectInfoType infoType,
|
||||
ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION lpJobObjectInfo,
|
||||
int cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool SetInformationJobObject(
|
||||
SafeFileHandle hJob,
|
||||
JobObjectInfoType infoType,
|
||||
ref JOBOBJECT_CPU_RATE_CONTROL_INFORMATION lpJobObjectInfo,
|
||||
int cbJobObjectInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool AssignProcessToJobObject(SafeFileHandle hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool TerminateJobObject(SafeFileHandle hJob, uint uExitCode);
|
||||
}
|
||||
}
|
||||
243
src/Plugin/StellaOps.Plugin.Sandbox/SandboxConfiguration.cs
Normal file
243
src/Plugin/StellaOps.Plugin.Sandbox/SandboxConfiguration.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for plugin sandbox.
|
||||
/// </summary>
|
||||
public sealed record SandboxConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Resource limits for the sandbox.
|
||||
/// </summary>
|
||||
public required ResourceLimits ResourceLimits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Network policy for the sandbox.
|
||||
/// </summary>
|
||||
public required NetworkPolicy NetworkPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem policy for the sandbox.
|
||||
/// </summary>
|
||||
public required FilesystemPolicy FilesystemPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeouts for sandbox operations.
|
||||
/// </summary>
|
||||
public required SandboxTimeouts Timeouts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable process isolation.
|
||||
/// </summary>
|
||||
public bool ProcessIsolation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Working directory for the sandbox. If null, a temporary directory is created.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment variables to pass to the sandbox.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; init; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable log streaming from the sandbox.
|
||||
/// </summary>
|
||||
public bool EnableLogStreaming { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default configuration for untrusted plugins.
|
||||
/// </summary>
|
||||
public static SandboxConfiguration Default => new()
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 512,
|
||||
MaxCpuPercent = 25,
|
||||
MaxDiskMb = 100,
|
||||
MaxNetworkBandwidthMbps = 10
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int> { 22, 3389, 5432, 27017, 6379 }
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
ReadOnlyPaths = new List<string>(),
|
||||
WritablePaths = new List<string>(),
|
||||
BlockedPaths = new List<string> { "/etc", "/var", "/root", "C:\\Windows" }
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(30),
|
||||
OperationTimeout = TimeSpan.FromSeconds(60),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(10),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(5)
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for trusted plugins with relaxed limits.
|
||||
/// </summary>
|
||||
public static SandboxConfiguration Trusted => new()
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 2048,
|
||||
MaxCpuPercent = 50,
|
||||
MaxDiskMb = 1024,
|
||||
MaxNetworkBandwidthMbps = 100
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int>(),
|
||||
AllowAllHosts = true
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
ReadOnlyPaths = new List<string>(),
|
||||
WritablePaths = new List<string>(),
|
||||
BlockedPaths = new List<string>()
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(60),
|
||||
OperationTimeout = TimeSpan.FromMinutes(5),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(30),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(10)
|
||||
},
|
||||
ProcessIsolation = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record ResourceLimits
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum memory in megabytes.
|
||||
/// </summary>
|
||||
public int MaxMemoryMb { get; init; } = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum CPU usage as a percentage.
|
||||
/// </summary>
|
||||
public int MaxCpuPercent { get; init; } = 25;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum disk usage in megabytes.
|
||||
/// </summary>
|
||||
public int MaxDiskMb { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum network bandwidth in Mbps.
|
||||
/// </summary>
|
||||
public int MaxNetworkBandwidthMbps { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of open files.
|
||||
/// </summary>
|
||||
public int MaxOpenFiles { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of processes/threads.
|
||||
/// </summary>
|
||||
public int MaxProcesses { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Network policy for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record NetworkPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Hosts that are explicitly allowed.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AllowedHosts { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Hosts that are explicitly blocked.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> BlockedHosts { get; init; } = new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Ports that are explicitly allowed.
|
||||
/// </summary>
|
||||
public IReadOnlySet<int> AllowedPorts { get; init; } = new HashSet<int> { 80, 443 };
|
||||
|
||||
/// <summary>
|
||||
/// Ports that are explicitly blocked.
|
||||
/// </summary>
|
||||
public IReadOnlySet<int> BlockedPorts { get; init; } = new HashSet<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow DNS resolution.
|
||||
/// </summary>
|
||||
public bool AllowDns { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow all hosts (ignores AllowedHosts).
|
||||
/// </summary>
|
||||
public bool AllowAllHosts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum connections per host.
|
||||
/// </summary>
|
||||
public int MaxConnectionsPerHost { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem policy for sandbox execution.
|
||||
/// </summary>
|
||||
public sealed record FilesystemPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Paths that can be read but not written.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ReadOnlyPaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Paths that can be written to.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> WritablePaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Paths that cannot be accessed at all.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BlockedPaths { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total bytes that can be written.
|
||||
/// </summary>
|
||||
public long MaxWriteBytes { get; init; } = 100 * 1024 * 1024; // 100 MB
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout configuration for sandbox operations.
|
||||
/// </summary>
|
||||
public sealed record SandboxTimeouts
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum time to wait for sandbox startup.
|
||||
/// </summary>
|
||||
public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for operations.
|
||||
/// </summary>
|
||||
public TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time to wait for graceful shutdown.
|
||||
/// </summary>
|
||||
public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for health checks.
|
||||
/// </summary>
|
||||
public TimeSpan HealthCheckTimeout { get; init; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
167
src/Plugin/StellaOps.Plugin.Sandbox/SandboxFactory.cs
Normal file
167
src/Plugin/StellaOps.Plugin.Sandbox/SandboxFactory.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Sandbox.Communication;
|
||||
using StellaOps.Plugin.Sandbox.Network;
|
||||
using StellaOps.Plugin.Sandbox.Process;
|
||||
using StellaOps.Plugin.Sandbox.Resources;
|
||||
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating sandbox instances.
|
||||
/// </summary>
|
||||
public sealed class SandboxFactory : ISandboxFactory
|
||||
{
|
||||
private readonly IPluginProcessManager _processManager;
|
||||
private readonly INetworkPolicyEnforcer _networkEnforcer;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private int _sandboxCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sandbox factory.
|
||||
/// </summary>
|
||||
public SandboxFactory(
|
||||
IPluginProcessManager processManager,
|
||||
INetworkPolicyEnforcer networkEnforcer,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_processManager = processManager;
|
||||
_networkEnforcer = networkEnforcer;
|
||||
_loggerFactory = loggerFactory;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox Create(SandboxConfiguration configuration)
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
return CreateInternal(id, configuration);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox CreateDefault()
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
return CreateInternal(id, SandboxConfiguration.Default);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISandbox CreateForTrustLevel(PluginTrustLevel trustLevel)
|
||||
{
|
||||
var id = GenerateSandboxId();
|
||||
var config = GetConfigurationForTrustLevel(trustLevel);
|
||||
return CreateInternal(id, config);
|
||||
}
|
||||
|
||||
private ISandbox CreateInternal(string id, SandboxConfiguration config)
|
||||
{
|
||||
// Create platform-specific resource limiter
|
||||
var resourceLimiter = CreateResourceLimiter();
|
||||
|
||||
// Create gRPC bridge
|
||||
var bridge = new GrpcPluginBridge(
|
||||
_loggerFactory.CreateLogger<GrpcPluginBridge>());
|
||||
|
||||
return new ProcessSandbox(
|
||||
id,
|
||||
config,
|
||||
_processManager,
|
||||
bridge,
|
||||
resourceLimiter,
|
||||
_networkEnforcer,
|
||||
_loggerFactory.CreateLogger<ProcessSandbox>(),
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private IResourceLimiter CreateResourceLimiter()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return new WindowsResourceLimiter(
|
||||
_loggerFactory.CreateLogger<WindowsResourceLimiter>());
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LinuxResourceLimiter(
|
||||
_loggerFactory.CreateLogger<LinuxResourceLimiter>());
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateSandboxId()
|
||||
{
|
||||
var counter = Interlocked.Increment(ref _sandboxCounter);
|
||||
return $"sandbox-{_timeProvider.GetUtcNow():yyyyMMdd-HHmmss}-{counter:D4}";
|
||||
}
|
||||
|
||||
private static SandboxConfiguration GetConfigurationForTrustLevel(PluginTrustLevel trustLevel)
|
||||
{
|
||||
return trustLevel switch
|
||||
{
|
||||
PluginTrustLevel.Untrusted => new SandboxConfiguration
|
||||
{
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 256,
|
||||
MaxCpuPercent = 25,
|
||||
MaxProcesses = 20,
|
||||
MaxOpenFiles = 50,
|
||||
MaxDiskMb = 50
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowAllHosts = false,
|
||||
AllowedHosts = new HashSet<string>(),
|
||||
BlockedPorts = new HashSet<int> { 22, 23, 25, 53, 135, 139, 445, 1433, 3306, 5432 }
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
MaxWriteBytes = 10 * 1024 * 1024 // 10 MB
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromSeconds(10),
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(5),
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(3)
|
||||
}
|
||||
},
|
||||
|
||||
PluginTrustLevel.Trusted => SandboxConfiguration.Trusted,
|
||||
|
||||
PluginTrustLevel.BuiltIn => new SandboxConfiguration
|
||||
{
|
||||
// BuiltIn plugins get generous limits (though they typically run in-process)
|
||||
ResourceLimits = new ResourceLimits
|
||||
{
|
||||
MaxMemoryMb = 4096,
|
||||
MaxCpuPercent = 100,
|
||||
MaxProcesses = 500,
|
||||
MaxOpenFiles = 1000,
|
||||
MaxDiskMb = 2048
|
||||
},
|
||||
NetworkPolicy = new NetworkPolicy
|
||||
{
|
||||
AllowAllHosts = true,
|
||||
BlockedPorts = new HashSet<int>()
|
||||
},
|
||||
FilesystemPolicy = new FilesystemPolicy
|
||||
{
|
||||
MaxWriteBytes = 1024 * 1024 * 1024 // 1 GB
|
||||
},
|
||||
Timeouts = new SandboxTimeouts
|
||||
{
|
||||
StartupTimeout = TimeSpan.FromMinutes(5),
|
||||
ShutdownTimeout = TimeSpan.FromMinutes(2),
|
||||
OperationTimeout = TimeSpan.FromMinutes(10),
|
||||
HealthCheckTimeout = TimeSpan.FromSeconds(15)
|
||||
},
|
||||
ProcessIsolation = false
|
||||
},
|
||||
|
||||
_ => SandboxConfiguration.Default
|
||||
};
|
||||
}
|
||||
}
|
||||
110
src/Plugin/StellaOps.Plugin.Sandbox/SandboxState.cs
Normal file
110
src/Plugin/StellaOps.Plugin.Sandbox/SandboxState.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace StellaOps.Plugin.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of a plugin sandbox.
|
||||
/// </summary>
|
||||
public enum SandboxState
|
||||
{
|
||||
/// <summary>
|
||||
/// Sandbox has been created but not started.
|
||||
/// </summary>
|
||||
Created,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is in the process of starting.
|
||||
/// </summary>
|
||||
Starting,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is running and ready for operations.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox is in the process of stopping gracefully.
|
||||
/// </summary>
|
||||
Stopping,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox has stopped gracefully.
|
||||
/// </summary>
|
||||
Stopped,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox has failed due to an error.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox was forcefully killed.
|
||||
/// </summary>
|
||||
Killed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for sandbox state changes.
|
||||
/// </summary>
|
||||
public sealed class SandboxStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Previous state before the change.
|
||||
/// </summary>
|
||||
public required SandboxState OldState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New state after the change.
|
||||
/// </summary>
|
||||
public required SandboxState NewState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason for the state change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for resource usage warnings.
|
||||
/// </summary>
|
||||
public sealed class ResourceWarningEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of resource approaching its limit.
|
||||
/// </summary>
|
||||
public required ResourceType Resource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current usage as a percentage of the limit.
|
||||
/// </summary>
|
||||
public required double CurrentUsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold percentage that triggered the warning.
|
||||
/// </summary>
|
||||
public required double ThresholdPercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of resources that can be monitored and limited.
|
||||
/// </summary>
|
||||
public enum ResourceType
|
||||
{
|
||||
/// <summary>
|
||||
/// Memory usage.
|
||||
/// </summary>
|
||||
Memory,
|
||||
|
||||
/// <summary>
|
||||
/// CPU usage.
|
||||
/// </summary>
|
||||
Cpu,
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage.
|
||||
/// </summary>
|
||||
Disk,
|
||||
|
||||
/// <summary>
|
||||
/// Network bandwidth usage.
|
||||
/// </summary>
|
||||
Network
|
||||
}
|
||||
95
src/Plugin/StellaOps.Plugin.Sandbox/Secrets/ISecretProxy.cs
Normal file
95
src/Plugin/StellaOps.Plugin.Sandbox/Secrets/ISecretProxy.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace StellaOps.Plugin.Sandbox.Secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for proxying secret access to sandboxed plugins.
|
||||
/// </summary>
|
||||
public interface ISecretProxy
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a secret value by key.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Secret value, or null if not found or not allowed.</returns>
|
||||
Task<string?> GetSecretAsync(string key, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Check if access to a secret is allowed.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <returns>True if access is allowed.</returns>
|
||||
bool IsAllowed(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Get the list of allowed secret prefixes.
|
||||
/// </summary>
|
||||
/// <returns>Allowed secret prefixes.</returns>
|
||||
IReadOnlyList<string> GetAllowedPrefixes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoped secret proxy that limits access to specific prefixes.
|
||||
/// </summary>
|
||||
public sealed class ScopedSecretProxy : ISecretProxy
|
||||
{
|
||||
private readonly ISecretProvider _provider;
|
||||
private readonly IReadOnlyList<string> _allowedPrefixes;
|
||||
private readonly string _sandboxId;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new scoped secret proxy.
|
||||
/// </summary>
|
||||
/// <param name="provider">Underlying secret provider.</param>
|
||||
/// <param name="allowedPrefixes">Allowed secret key prefixes.</param>
|
||||
/// <param name="sandboxId">Sandbox identifier for auditing.</param>
|
||||
public ScopedSecretProxy(
|
||||
ISecretProvider provider,
|
||||
IReadOnlyList<string> allowedPrefixes,
|
||||
string sandboxId)
|
||||
{
|
||||
_provider = provider;
|
||||
_allowedPrefixes = allowedPrefixes;
|
||||
_sandboxId = sandboxId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetSecretAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (!IsAllowed(key))
|
||||
return null;
|
||||
|
||||
return await _provider.GetSecretAsync(key, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAllowed(string key)
|
||||
{
|
||||
if (_allowedPrefixes.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (var prefix in _allowedPrefixes)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetAllowedPrefixes() => _allowedPrefixes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for providing secrets.
|
||||
/// </summary>
|
||||
public interface ISecretProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a secret value by key.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Secret value, or null if not found.</returns>
|
||||
Task<string?> GetSecretAsync(string key, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Plugin sandbox infrastructure for process isolation, resource limits, and security boundaries</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" />
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Communication\Proto\plugin_bridge.proto" GrpcServices="Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
157
src/Plugin/StellaOps.Plugin.Sdk/PluginBase.cs
Normal file
157
src/Plugin/StellaOps.Plugin.Sdk/PluginBase.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
namespace StellaOps.Plugin.Sdk;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
using StellaOps.Plugin.Abstractions.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for simplified plugin development.
|
||||
/// Provides common patterns and reduces boilerplate.
|
||||
/// </summary>
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
private IPluginContext? _context;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin context.
|
||||
/// </summary>
|
||||
protected IPluginContext Context => _context ?? throw new InvalidOperationException("Plugin not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin logger.
|
||||
/// </summary>
|
||||
protected IPluginLogger Logger => _context?.Logger ?? NullPluginLogger.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin configuration.
|
||||
/// </summary>
|
||||
protected IPluginConfiguration Configuration => _context?.Configuration ?? EmptyConfiguration.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time provider.
|
||||
/// </summary>
|
||||
protected TimeProvider TimeProvider => _context?.TimeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin services.
|
||||
/// </summary>
|
||||
protected IPluginServices Services => _context?.Services ?? throw new InvalidOperationException("Plugin not initialized");
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract PluginInfo Info { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual PluginTrustLevel TrustLevel => PluginTrustLevel.Untrusted;
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract PluginCapabilities Capabilities { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public PluginLifecycleState State { get; protected set; } = PluginLifecycleState.Discovered;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
{
|
||||
_context = context;
|
||||
State = PluginLifecycleState.Initializing;
|
||||
|
||||
try
|
||||
{
|
||||
await OnInitializeAsync(context, ct);
|
||||
State = PluginLifecycleState.Active;
|
||||
Logger.Info("Plugin {PluginId} initialized successfully", Info.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State = PluginLifecycleState.Failed;
|
||||
Logger.Error(ex, "Plugin {PluginId} failed to initialize", Info.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to add initialization logic.
|
||||
/// </summary>
|
||||
/// <param name="context">The plugin context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
protected virtual Task OnInitializeAsync(IPluginContext context, CancellationToken ct)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(State == PluginLifecycleState.Active
|
||||
? HealthCheckResult.Healthy()
|
||||
: HealthCheckResult.Unhealthy($"Plugin is in state {State}"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await OnDisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
State = PluginLifecycleState.Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to add cleanup logic.
|
||||
/// </summary>
|
||||
protected virtual ValueTask OnDisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the plugin is in active state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If plugin is not active.</exception>
|
||||
protected void EnsureActive()
|
||||
{
|
||||
if (State != PluginLifecycleState.Active)
|
||||
throw new InvalidOperationException($"{Info.Name} is not active (state: {State})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of plugin logger that discards all log messages.
|
||||
/// </summary>
|
||||
internal sealed class NullPluginLogger : IPluginLogger
|
||||
{
|
||||
public static readonly NullPluginLogger Instance = new();
|
||||
|
||||
private NullPluginLogger() { }
|
||||
|
||||
public void Log(LogLevel level, string message, params object[] args) { }
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args) { }
|
||||
public void Debug(string message, params object[] args) { }
|
||||
public void Info(string message, params object[] args) { }
|
||||
public void Warning(string message, params object[] args) { }
|
||||
public void Error(string message, params object[] args) { }
|
||||
public void Error(Exception ex, string message, params object[] args) { }
|
||||
public IPluginLogger WithProperty(string name, object value) => this;
|
||||
public IPluginLogger ForOperation(string operationName) => this;
|
||||
public bool IsEnabled(LogLevel level) => false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty configuration that returns defaults for all values.
|
||||
/// </summary>
|
||||
internal sealed class EmptyConfiguration : IPluginConfiguration
|
||||
{
|
||||
public static readonly EmptyConfiguration Instance = new();
|
||||
|
||||
private EmptyConfiguration() { }
|
||||
|
||||
public T? GetValue<T>(string key, T? defaultValue = default) => defaultValue;
|
||||
|
||||
public T Bind<T>(string? sectionKey = null) where T : class, new() => new();
|
||||
|
||||
public Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
|
||||
=> Task.FromResult<string?>(null);
|
||||
|
||||
public bool HasKey(string key) => false;
|
||||
}
|
||||
33
src/Plugin/StellaOps.Plugin.Sdk/PluginConfigAttribute.cs
Normal file
33
src/Plugin/StellaOps.Plugin.Sdk/PluginConfigAttribute.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.Plugin.Sdk;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute for marking plugin configuration properties.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class PluginConfigAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration key. If not specified, property name is used.
|
||||
/// </summary>
|
||||
public string? Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the configuration option.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the configuration is required.
|
||||
/// </summary>
|
||||
public bool Required { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not specified.
|
||||
/// </summary>
|
||||
public object? DefaultValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a secret value (should not be logged).
|
||||
/// </summary>
|
||||
public bool Secret { get; set; }
|
||||
}
|
||||
83
src/Plugin/StellaOps.Plugin.Sdk/PluginExtensions.cs
Normal file
83
src/Plugin/StellaOps.Plugin.Sdk/PluginExtensions.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
namespace StellaOps.Plugin.Sdk;
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for common plugin operations.
|
||||
/// </summary>
|
||||
public static class PluginExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get required configuration value.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Target type.</typeparam>
|
||||
/// <param name="config">Configuration instance.</param>
|
||||
/// <param name="key">Configuration key.</param>
|
||||
/// <returns>The configuration value.</returns>
|
||||
/// <exception cref="InvalidOperationException">If key is not found.</exception>
|
||||
public static T GetRequiredValue<T>(this IPluginConfiguration config, string key)
|
||||
{
|
||||
var value = config.GetValue<T>(key);
|
||||
if (value == null)
|
||||
throw new InvalidOperationException($"Required configuration key '{key}' not found");
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get secret with caching.
|
||||
/// </summary>
|
||||
/// <param name="config">Configuration instance.</param>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="cacheDuration">How long to cache the secret.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The secret value or null.</returns>
|
||||
public static async Task<string?> GetCachedSecretAsync(
|
||||
this IPluginConfiguration config,
|
||||
string key,
|
||||
TimeSpan cacheDuration,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Implementation would cache secrets to reduce vault calls
|
||||
// For now, just pass through to GetSecretAsync
|
||||
return await config.GetSecretAsync(key, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a scoped logger for a specific operation.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="operationName">Name of the operation.</param>
|
||||
/// <returns>Disposable scope that logs completion time.</returns>
|
||||
public static IDisposable BeginScope(this IPluginLogger logger, string operationName)
|
||||
{
|
||||
logger.Debug("Starting operation: {Operation}", operationName);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
return new ScopeDisposable(() =>
|
||||
{
|
||||
sw.Stop();
|
||||
logger.Debug("Completed operation: {Operation} in {Elapsed}ms",
|
||||
operationName, sw.ElapsedMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get HTTP client from services.
|
||||
/// </summary>
|
||||
/// <param name="services">Plugin services.</param>
|
||||
/// <param name="name">Named client name.</param>
|
||||
/// <returns>HttpClient instance.</returns>
|
||||
public static HttpClient GetHttpClient(this IPluginServices services, string name = "")
|
||||
{
|
||||
var factory = services.GetRequiredService<IHttpClientFactory>();
|
||||
return factory.CreateClient(name);
|
||||
}
|
||||
|
||||
private sealed class ScopeDisposable(Action onDispose) : IDisposable
|
||||
{
|
||||
public void Dispose() => onDispose();
|
||||
}
|
||||
}
|
||||
120
src/Plugin/StellaOps.Plugin.Sdk/PluginInfoBuilder.cs
Normal file
120
src/Plugin/StellaOps.Plugin.Sdk/PluginInfoBuilder.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace StellaOps.Plugin.Sdk;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating PluginInfo.
|
||||
/// </summary>
|
||||
public sealed class PluginInfoBuilder
|
||||
{
|
||||
private string _id = "";
|
||||
private string _name = "";
|
||||
private string _version = "1.0.0";
|
||||
private string _vendor = "";
|
||||
private string? _description;
|
||||
private string? _licenseId;
|
||||
private string? _projectUrl;
|
||||
private string? _iconUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin ID.
|
||||
/// </summary>
|
||||
/// <param name="id">Plugin ID in reverse domain notation (e.g., com.example.plugin).</param>
|
||||
public PluginInfoBuilder WithId(string id)
|
||||
{
|
||||
_id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin name.
|
||||
/// </summary>
|
||||
/// <param name="name">Human-readable plugin name.</param>
|
||||
public PluginInfoBuilder WithName(string name)
|
||||
{
|
||||
_name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin version.
|
||||
/// </summary>
|
||||
/// <param name="version">SemVer version string.</param>
|
||||
public PluginInfoBuilder WithVersion(string version)
|
||||
{
|
||||
_version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin vendor.
|
||||
/// </summary>
|
||||
/// <param name="vendor">Vendor/company name.</param>
|
||||
public PluginInfoBuilder WithVendor(string vendor)
|
||||
{
|
||||
_vendor = vendor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin description.
|
||||
/// </summary>
|
||||
/// <param name="description">Description of what the plugin does.</param>
|
||||
public PluginInfoBuilder WithDescription(string description)
|
||||
{
|
||||
_description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin license.
|
||||
/// </summary>
|
||||
/// <param name="licenseId">SPDX license identifier.</param>
|
||||
public PluginInfoBuilder WithLicense(string licenseId)
|
||||
{
|
||||
_licenseId = licenseId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin project URL.
|
||||
/// </summary>
|
||||
/// <param name="projectUrl">Project URL.</param>
|
||||
public PluginInfoBuilder WithProjectUrl(string projectUrl)
|
||||
{
|
||||
_projectUrl = projectUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the plugin icon URL.
|
||||
/// </summary>
|
||||
/// <param name="iconUrl">Icon URL.</param>
|
||||
public PluginInfoBuilder WithIconUrl(string iconUrl)
|
||||
{
|
||||
_iconUrl = iconUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the PluginInfo.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If required fields are not set.</exception>
|
||||
public PluginInfo Build()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_id))
|
||||
throw new InvalidOperationException("Plugin ID is required");
|
||||
if (string.IsNullOrEmpty(_name))
|
||||
throw new InvalidOperationException("Plugin name is required");
|
||||
|
||||
return new PluginInfo(
|
||||
Id: _id,
|
||||
Name: _name,
|
||||
Version: _version,
|
||||
Vendor: _vendor,
|
||||
Description: _description,
|
||||
LicenseId: _licenseId,
|
||||
ProjectUrl: _projectUrl,
|
||||
IconUrl: _iconUrl);
|
||||
}
|
||||
}
|
||||
35
src/Plugin/StellaOps.Plugin.Sdk/PluginOptionsBase.cs
Normal file
35
src/Plugin/StellaOps.Plugin.Sdk/PluginOptionsBase.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.Plugin.Sdk;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
/// <summary>
|
||||
/// Options base class with validation support.
|
||||
/// </summary>
|
||||
public abstract class PluginOptionsBase : IValidatableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Override to add custom validation logic.
|
||||
/// </summary>
|
||||
/// <param name="validationContext">Validation context.</param>
|
||||
/// <returns>Validation results.</returns>
|
||||
public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the options and throws if invalid.
|
||||
/// </summary>
|
||||
/// <exception cref="ValidationException">If validation fails.</exception>
|
||||
public void ValidateAndThrow()
|
||||
{
|
||||
var context = new ValidationContext(this);
|
||||
var results = new List<ValidationResult>();
|
||||
|
||||
if (!Validator.TryValidateObject(this, context, results, validateAllProperties: true))
|
||||
{
|
||||
var errors = string.Join(", ", results.Select(r => r.ErrorMessage));
|
||||
throw new ValidationException($"Configuration validation failed: {errors}");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Plugin/StellaOps.Plugin.Sdk/StellaOps.Plugin.Sdk.csproj
Normal file
20
src/Plugin/StellaOps.Plugin.Sdk/StellaOps.Plugin.Sdk.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Plugin SDK for building Stella Ops plugins</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
76
src/Plugin/StellaOps.Plugin.Testing/FakeTimeProvider.cs
Normal file
76
src/Plugin/StellaOps.Plugin.Testing/FakeTimeProvider.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for deterministic testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fake time provider starting at the specified time.
|
||||
/// </summary>
|
||||
/// <param name="startTime">Initial time.</param>
|
||||
public FakeTimeProvider(DateTimeOffset startTime)
|
||||
{
|
||||
_now = startTime;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified duration.
|
||||
/// </summary>
|
||||
/// <param name="duration">Duration to advance.</param>
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_now += duration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current time to the specified value.
|
||||
/// </summary>
|
||||
/// <param name="time">New current time.</param>
|
||||
public void SetTime(DateTimeOffset time)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_now = time;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of seconds.
|
||||
/// </summary>
|
||||
/// <param name="seconds">Number of seconds to advance.</param>
|
||||
public void AdvanceSeconds(double seconds) => Advance(TimeSpan.FromSeconds(seconds));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of minutes.
|
||||
/// </summary>
|
||||
/// <param name="minutes">Number of minutes to advance.</param>
|
||||
public void AdvanceMinutes(double minutes) => Advance(TimeSpan.FromMinutes(minutes));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of hours.
|
||||
/// </summary>
|
||||
/// <param name="hours">Number of hours to advance.</param>
|
||||
public void AdvanceHours(double hours) => Advance(TimeSpan.FromHours(hours));
|
||||
|
||||
/// <summary>
|
||||
/// Advances time by the specified number of days.
|
||||
/// </summary>
|
||||
/// <param name="days">Number of days to advance.</param>
|
||||
public void AdvanceDays(double days) => Advance(TimeSpan.FromDays(days));
|
||||
}
|
||||
72
src/Plugin/StellaOps.Plugin.Testing/PluginTestBase.cs
Normal file
72
src/Plugin/StellaOps.Plugin.Testing/PluginTestBase.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit test base class for plugin testing.
|
||||
/// Provides automatic setup and teardown of plugin test infrastructure.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPlugin">The plugin type to test.</typeparam>
|
||||
public abstract class PluginTestBase<TPlugin> : IAsyncLifetime where TPlugin : IPlugin, new()
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the test host.
|
||||
/// </summary>
|
||||
protected PluginTestHost Host { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin under test.
|
||||
/// </summary>
|
||||
protected TPlugin Plugin { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test context.
|
||||
/// </summary>
|
||||
protected TestPluginContext Context => Host.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test logger.
|
||||
/// </summary>
|
||||
protected TestPluginLogger Logger => Context.Logger;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test configuration.
|
||||
/// </summary>
|
||||
protected TestPluginConfiguration Configuration => Context.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fake time provider.
|
||||
/// </summary>
|
||||
protected FakeTimeProvider? FakeTimeProvider => Context.TimeProvider as FakeTimeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP client factory.
|
||||
/// </summary>
|
||||
protected TestHttpClientFactory HttpClientFactory => (TestHttpClientFactory)Context.HttpClientFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Override to configure the test host.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options to configure.</param>
|
||||
protected virtual void ConfigureHost(PluginTestHostOptions options) { }
|
||||
|
||||
/// <summary>
|
||||
/// Override to provide configuration for the plugin.
|
||||
/// </summary>
|
||||
/// <returns>Configuration dictionary.</returns>
|
||||
protected virtual Dictionary<string, object> GetConfiguration() => new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask InitializeAsync()
|
||||
{
|
||||
Host = new PluginTestHost(ConfigureHost);
|
||||
Plugin = await Host.LoadPluginAsync<TPlugin>(GetConfiguration());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
await Host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Testing/PluginTestHost.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Testing/PluginTestHost.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions;
|
||||
using StellaOps.Plugin.Abstractions.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Test host for running plugins in isolation during testing.
|
||||
/// </summary>
|
||||
public sealed class PluginTestHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<IPlugin> _plugins = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test context for assertions.
|
||||
/// </summary>
|
||||
public TestPluginContext Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new plugin test host.
|
||||
/// </summary>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
public PluginTestHost(Action<PluginTestHostOptions>? configure = null)
|
||||
{
|
||||
var options = new PluginTestHostOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
Context = new TestPluginContext(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load and initialize a plugin.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Initialized plugin instance.</returns>
|
||||
public async Task<T> LoadPluginAsync<T>(CancellationToken ct = default) where T : IPlugin, new()
|
||||
{
|
||||
var plugin = new T();
|
||||
await plugin.InitializeAsync(Context, ct);
|
||||
_plugins.Add(plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load and initialize a plugin with custom configuration.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="configuration">Configuration values to set.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Initialized plugin instance.</returns>
|
||||
public async Task<T> LoadPluginAsync<T>(
|
||||
Dictionary<string, object> configuration,
|
||||
CancellationToken ct = default) where T : IPlugin, new()
|
||||
{
|
||||
foreach (var (key, value) in configuration)
|
||||
{
|
||||
Context.Configuration.SetValue(key, value);
|
||||
}
|
||||
|
||||
return await LoadPluginAsync<T>(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify plugin health.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Plugin type.</typeparam>
|
||||
/// <param name="plugin">Plugin to check.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Health check result.</returns>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync<T>(T plugin, CancellationToken ct = default)
|
||||
where T : IPlugin
|
||||
{
|
||||
return await plugin.HealthCheckAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var plugin in _plugins)
|
||||
{
|
||||
await plugin.DisposeAsync();
|
||||
}
|
||||
_plugins.Clear();
|
||||
}
|
||||
}
|
||||
39
src/Plugin/StellaOps.Plugin.Testing/PluginTestHostOptions.cs
Normal file
39
src/Plugin/StellaOps.Plugin.Testing/PluginTestHostOptions.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring the plugin test host.
|
||||
/// </summary>
|
||||
public sealed class PluginTestHostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enable logging output.
|
||||
/// </summary>
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum log level to capture.
|
||||
/// </summary>
|
||||
public LogLevel MinLogLevel { get; set; } = LogLevel.Debug;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom time provider for deterministic testing.
|
||||
/// </summary>
|
||||
public TimeProvider? TimeProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret values for testing.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Secrets { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Configuration values for testing.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> Configuration { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Start time for fake time provider (if not using custom TimeProvider).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Sequential GUID generator for deterministic testing.
|
||||
/// Generates GUIDs in a predictable sequence based on a counter.
|
||||
/// </summary>
|
||||
public sealed class SequentialGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sequential GUID generator.
|
||||
/// </summary>
|
||||
public SequentialGuidGenerator()
|
||||
{
|
||||
_counter = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new sequential GUID generator starting at the specified counter value.
|
||||
/// </summary>
|
||||
/// <param name="startValue">Initial counter value.</param>
|
||||
public SequentialGuidGenerator(int startValue)
|
||||
{
|
||||
_counter = startValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new sequential GUID.
|
||||
/// </summary>
|
||||
public Guid NewGuid()
|
||||
{
|
||||
var counter = Interlocked.Increment(ref _counter);
|
||||
var bytes = new byte[16];
|
||||
|
||||
// Put counter in first 4 bytes for easy debugging
|
||||
BitConverter.GetBytes(counter).CopyTo(bytes, 0);
|
||||
|
||||
// Fill rest with deterministic pattern
|
||||
bytes[4] = 0x00;
|
||||
bytes[5] = 0x00;
|
||||
bytes[6] = 0x40; // Version 4
|
||||
bytes[7] = 0x00;
|
||||
bytes[8] = 0x80; // Variant
|
||||
bytes[9] = 0x00;
|
||||
bytes[10] = 0x00;
|
||||
bytes[11] = 0x00;
|
||||
bytes[12] = 0x00;
|
||||
bytes[13] = 0x00;
|
||||
bytes[14] = 0x00;
|
||||
bytes[15] = 0x00;
|
||||
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the counter to zero.
|
||||
/// </summary>
|
||||
public void Reset() => _counter = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Resets the counter to the specified value.
|
||||
/// </summary>
|
||||
/// <param name="value">New counter value.</param>
|
||||
public void Reset(int value) => _counter = value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current counter value.
|
||||
/// </summary>
|
||||
public int CurrentValue => _counter;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Testing utilities for Stella Ops plugins</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
224
src/Plugin/StellaOps.Plugin.Testing/TestHttpClientFactory.cs
Normal file
224
src/Plugin/StellaOps.Plugin.Testing/TestHttpClientFactory.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Test HTTP client factory with request recording and response mocking.
|
||||
/// </summary>
|
||||
public sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly Dictionary<string, MockHttpMessageHandler> _handlers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<RecordedRequest> _requests = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all recorded requests across all clients.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RecordedRequest> RecordedRequests
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _requests.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handlers.TryGetValue(name, out var handler))
|
||||
{
|
||||
handler = new MockHttpMessageHandler(_requests, _lock);
|
||||
_handlers[name] = handler;
|
||||
}
|
||||
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="response">Response to return.</param>
|
||||
public void SetupResponse(string clientName, string urlPattern, HttpResponseMessage response)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handlers.TryGetValue(clientName, out var handler))
|
||||
{
|
||||
handler = new MockHttpMessageHandler(_requests, _lock);
|
||||
_handlers[clientName] = handler;
|
||||
}
|
||||
|
||||
handler.SetupResponse(urlPattern, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a JSON response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="json">JSON content to return.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
public void SetupJsonResponse(
|
||||
string clientName,
|
||||
string urlPattern,
|
||||
string json,
|
||||
HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
|
||||
};
|
||||
SetupResponse(clientName, urlPattern, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up an error response for a specific URL pattern.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Name of the HTTP client.</param>
|
||||
/// <param name="urlPattern">URL pattern to match.</param>
|
||||
/// <param name="statusCode">HTTP status code.</param>
|
||||
/// <param name="reasonPhrase">Optional reason phrase.</param>
|
||||
public void SetupError(
|
||||
string clientName,
|
||||
string urlPattern,
|
||||
HttpStatusCode statusCode,
|
||||
string? reasonPhrase = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(statusCode)
|
||||
{
|
||||
ReasonPhrase = reasonPhrase
|
||||
};
|
||||
SetupResponse(clientName, urlPattern, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all recorded requests.
|
||||
/// </summary>
|
||||
public void ClearRecordedRequests()
|
||||
{
|
||||
lock (_lock) _requests.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all handlers and recorded requests.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_handlers.Clear();
|
||||
_requests.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recorded HTTP request.
|
||||
/// </summary>
|
||||
/// <param name="Method">HTTP method.</param>
|
||||
/// <param name="Uri">Request URI.</param>
|
||||
/// <param name="Headers">Request headers.</param>
|
||||
/// <param name="Content">Request content.</param>
|
||||
/// <param name="Timestamp">When the request was made.</param>
|
||||
public sealed record RecordedRequest(
|
||||
HttpMethod Method,
|
||||
Uri? Uri,
|
||||
Dictionary<string, IEnumerable<string>> Headers,
|
||||
string? Content,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
internal sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<RecordedRequest> _requests;
|
||||
private readonly object _lock;
|
||||
private readonly Dictionary<string, HttpResponseMessage> _responses = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MockHttpMessageHandler(List<RecordedRequest> requests, object lockObj)
|
||||
{
|
||||
_requests = requests;
|
||||
_lock = lockObj;
|
||||
}
|
||||
|
||||
public void SetupResponse(string urlPattern, HttpResponseMessage response)
|
||||
{
|
||||
_responses[urlPattern] = response;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Record the request
|
||||
string? content = null;
|
||||
if (request.Content != null)
|
||||
{
|
||||
content = await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var headers = request.Headers.ToDictionary(
|
||||
h => h.Key,
|
||||
h => h.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var recorded = new RecordedRequest(
|
||||
request.Method,
|
||||
request.RequestUri,
|
||||
headers,
|
||||
content,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_requests.Add(recorded);
|
||||
}
|
||||
|
||||
// Find matching response
|
||||
var url = request.RequestUri?.ToString() ?? "";
|
||||
foreach (var (pattern, response) in _responses)
|
||||
{
|
||||
if (url.Contains(pattern, StringComparison.OrdinalIgnoreCase) ||
|
||||
pattern == "*" ||
|
||||
System.Text.RegularExpressions.Regex.IsMatch(url, pattern))
|
||||
{
|
||||
// Clone response to allow reuse
|
||||
return await CloneResponseAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
ReasonPhrase = $"No mock response configured for {url}"
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> CloneResponseAsync(HttpResponseMessage response)
|
||||
{
|
||||
var clone = new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
ReasonPhrase = response.ReasonPhrase,
|
||||
Version = response.Version
|
||||
};
|
||||
|
||||
if (response.Content != null)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
clone.Content = new StringContent(
|
||||
content,
|
||||
System.Text.Encoding.UTF8,
|
||||
response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
124
src/Plugin/StellaOps.Plugin.Testing/TestPluginConfiguration.cs
Normal file
124
src/Plugin/StellaOps.Plugin.Testing/TestPluginConfiguration.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
using StellaOps.Plugin.Sdk;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of plugin configuration.
|
||||
/// </summary>
|
||||
public sealed class TestPluginConfiguration : IPluginConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, object> _values;
|
||||
private readonly Dictionary<string, string> _secrets;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test configuration.
|
||||
/// </summary>
|
||||
/// <param name="values">Initial configuration values.</param>
|
||||
/// <param name="secrets">Initial secret values.</param>
|
||||
public TestPluginConfiguration(
|
||||
Dictionary<string, object> values,
|
||||
Dictionary<string, string> secrets)
|
||||
{
|
||||
_values = new Dictionary<string, object>(values, StringComparer.OrdinalIgnoreCase);
|
||||
_secrets = new Dictionary<string, string>(secrets, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T? GetValue<T>(string key, T? defaultValue = default)
|
||||
{
|
||||
if (!_values.TryGetValue(key, out var value))
|
||||
return defaultValue;
|
||||
|
||||
if (value == null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||
return (T)Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a configuration value.
|
||||
/// </summary>
|
||||
/// <param name="key">Configuration key.</param>
|
||||
/// <param name="value">Configuration value.</param>
|
||||
public void SetValue(string key, object value)
|
||||
{
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public T Bind<T>(string? sectionKey = null) where T : class, new()
|
||||
{
|
||||
var result = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
var prefix = string.IsNullOrEmpty(sectionKey) ? "" : sectionKey + ":";
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (!prop.CanWrite) continue;
|
||||
|
||||
var key = prefix + prop.Name;
|
||||
var configAttr = prop.GetCustomAttribute<PluginConfigAttribute>();
|
||||
if (configAttr?.Key != null)
|
||||
key = prefix + configAttr.Key;
|
||||
|
||||
if (_values.TryGetValue(key, out var value))
|
||||
{
|
||||
try
|
||||
{
|
||||
var targetType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
var convertedValue = Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
prop.SetValue(result, convertedValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip properties that can't be converted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetSecretAsync(string secretName, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_secrets.TryGetValue(secretName, out var secret) ? secret : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasKey(string key)
|
||||
{
|
||||
return _values.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">Secret key.</param>
|
||||
/// <param name="value">Secret value.</param>
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
_secrets[key] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all configuration values.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_values.Clear();
|
||||
_secrets.Clear();
|
||||
}
|
||||
}
|
||||
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginContext.cs
Normal file
85
src/Plugin/StellaOps.Plugin.Testing/TestPluginContext.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of IPluginContext.
|
||||
/// </summary>
|
||||
public sealed class TestPluginContext : IPluginContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the test configuration.
|
||||
/// </summary>
|
||||
public TestPluginConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test logger.
|
||||
/// </summary>
|
||||
public TestPluginLogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the test services.
|
||||
/// </summary>
|
||||
public TestPluginServices Services { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid InstanceId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken ShutdownToken { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeProvider TimeProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GUID generator for deterministic testing.
|
||||
/// </summary>
|
||||
public SequentialGuidGenerator GuidGenerator { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the HTTP client factory for test mocking.
|
||||
/// </summary>
|
||||
public TestHttpClientFactory HttpClientFactory { get; }
|
||||
|
||||
IPluginConfiguration IPluginContext.Configuration => Configuration;
|
||||
IPluginLogger IPluginContext.Logger => Logger;
|
||||
IPluginServices IPluginContext.Services => Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test context.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options.</param>
|
||||
public TestPluginContext(PluginTestHostOptions options)
|
||||
{
|
||||
Configuration = new TestPluginConfiguration(options.Configuration, options.Secrets);
|
||||
Logger = new TestPluginLogger(options.MinLogLevel, options.EnableLogging);
|
||||
Services = new TestPluginServices();
|
||||
|
||||
TimeProvider = options.TimeProvider
|
||||
?? new FakeTimeProvider(options.StartTime ?? DateTimeOffset.UtcNow);
|
||||
|
||||
GuidGenerator = new SequentialGuidGenerator();
|
||||
HttpClientFactory = new TestHttpClientFactory();
|
||||
|
||||
InstanceId = GuidGenerator.NewGuid();
|
||||
ShutdownToken = CancellationToken.None;
|
||||
|
||||
// Register common services
|
||||
Services.Register<IHttpClientFactory>(HttpClientFactory);
|
||||
Services.Register(TimeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test context with a custom shutdown token.
|
||||
/// </summary>
|
||||
/// <param name="options">Test host options.</param>
|
||||
/// <param name="shutdownToken">Shutdown cancellation token.</param>
|
||||
public TestPluginContext(PluginTestHostOptions options, CancellationToken shutdownToken)
|
||||
: this(options)
|
||||
{
|
||||
ShutdownToken = shutdownToken;
|
||||
}
|
||||
}
|
||||
200
src/Plugin/StellaOps.Plugin.Testing/TestPluginLogger.cs
Normal file
200
src/Plugin/StellaOps.Plugin.Testing/TestPluginLogger.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
namespace StellaOps.Plugin.Testing;
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Abstractions.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Test logger that captures log entries for assertions.
|
||||
/// </summary>
|
||||
public sealed class TestPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly LogLevel _minLevel;
|
||||
private readonly bool _enabled;
|
||||
private readonly List<LogEntry> _entries = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, object> _properties = new();
|
||||
private readonly string? _operationName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all captured log entries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogEntry> Entries
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _entries.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new test logger.
|
||||
/// </summary>
|
||||
/// <param name="minLevel">Minimum log level to capture.</param>
|
||||
/// <param name="enabled">Whether logging is enabled.</param>
|
||||
public TestPluginLogger(LogLevel minLevel, bool enabled)
|
||||
{
|
||||
_minLevel = minLevel;
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
private TestPluginLogger(
|
||||
LogLevel minLevel,
|
||||
bool enabled,
|
||||
List<LogEntry> entries,
|
||||
object lockObj,
|
||||
Dictionary<string, object> properties,
|
||||
string? operationName)
|
||||
{
|
||||
_minLevel = minLevel;
|
||||
_enabled = enabled;
|
||||
_entries = entries;
|
||||
_lock = lockObj;
|
||||
_properties = new Dictionary<string, object>(properties);
|
||||
_operationName = operationName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, string message, params object[] args)
|
||||
{
|
||||
if (!_enabled || level < _minLevel) return;
|
||||
|
||||
var formatted = FormatMessage(message, args);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(new LogEntry(level, formatted, null, _operationName, new Dictionary<string, object>(_properties)));
|
||||
}
|
||||
|
||||
if (_enabled)
|
||||
{
|
||||
var prefix = _operationName != null ? $"[{_operationName}] " : "";
|
||||
Console.WriteLine($"[{level}] {prefix}{formatted}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log(LogLevel level, Exception exception, string message, params object[] args)
|
||||
{
|
||||
if (!_enabled || level < _minLevel) return;
|
||||
|
||||
var formatted = FormatMessage(message, args);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_entries.Add(new LogEntry(level, formatted, exception, _operationName, new Dictionary<string, object>(_properties)));
|
||||
}
|
||||
|
||||
if (_enabled)
|
||||
{
|
||||
var prefix = _operationName != null ? $"[{_operationName}] " : "";
|
||||
Console.WriteLine($"[{level}] {prefix}{formatted}");
|
||||
Console.WriteLine(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Info(string message, params object[] args) => Log(LogLevel.Information, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Error(string message, params object[] args) => Log(LogLevel.Error, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Error(Exception ex, string message, params object[] args) => Log(LogLevel.Error, ex, message, args);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger WithProperty(string name, object value)
|
||||
{
|
||||
var newProps = new Dictionary<string, object>(_properties)
|
||||
{
|
||||
[name] = value
|
||||
};
|
||||
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, newProps, _operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPluginLogger ForOperation(string operationName)
|
||||
{
|
||||
return new TestPluginLogger(_minLevel, _enabled, _entries, _lock, _properties, operationName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel level) => _enabled && level >= _minLevel;
|
||||
|
||||
private static string FormatMessage(string message, object[] args)
|
||||
{
|
||||
if (args.Length == 0) return message;
|
||||
|
||||
// Handle structured logging placeholders like {PluginId}
|
||||
var formatted = message;
|
||||
var argIndex = 0;
|
||||
formatted = System.Text.RegularExpressions.Regex.Replace(
|
||||
formatted,
|
||||
@"\{[^}]+\}",
|
||||
match =>
|
||||
{
|
||||
if (argIndex < args.Length)
|
||||
{
|
||||
var arg = args[argIndex++];
|
||||
return arg?.ToString() ?? "null";
|
||||
}
|
||||
return match.Value;
|
||||
},
|
||||
System.Text.RegularExpressions.RegexOptions.None,
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any entries were logged at the specified level.
|
||||
/// </summary>
|
||||
/// <param name="level">Log level to check.</param>
|
||||
public bool HasLoggedAtLevel(LogLevel level) => Entries.Any(e => e.Level == level);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any error was logged.
|
||||
/// </summary>
|
||||
public bool HasLoggedError() => HasLoggedAtLevel(LogLevel.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any warning was logged.
|
||||
/// </summary>
|
||||
public bool HasLoggedWarning() => HasLoggedAtLevel(LogLevel.Warning);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a message containing the specified text was logged.
|
||||
/// </summary>
|
||||
/// <param name="text">Text to search for.</param>
|
||||
public bool HasLoggedContaining(string text) =>
|
||||
Entries.Any(e => e.Message.Contains(text, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured log entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock) _entries.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A captured log entry.
|
||||
/// </summary>
|
||||
/// <param name="Level">Log level.</param>
|
||||
/// <param name="Message">Formatted message.</param>
|
||||
/// <param name="Exception">Optional exception.</param>
|
||||
/// <param name="OperationName">Optional operation name.</param>
|
||||
/// <param name="Properties">Scoped properties.</param>
|
||||
public sealed record LogEntry(
|
||||
LogLevel Level,
|
||||
string Message,
|
||||
Exception? Exception,
|
||||
string? OperationName = null,
|
||||
Dictionary<string, object>? Properties = null);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user