Files
git.stella-ops.org/docs/sdks/plugin-development.md

12 KiB

Plugin Development SDK

This guide covers the StellaOps Plugin SDK for developing custom plugins.

Overview

The Plugin SDK provides:

  • Base interfaces for all plugin types
  • DI registration utilities
  • Configuration binding helpers
  • Version compatibility attributes
  • Testing infrastructure

Prerequisites

  • .NET 10 SDK
  • Understanding of dependency injection
  • Familiarity with async/await patterns

Core Interfaces

IAvailabilityPlugin

Base interface for plugins with availability checks:

namespace StellaOps.Plugin;

public interface IAvailabilityPlugin
{
    /// <summary>
    /// Checks whether the plugin is available in the current environment.
    /// </summary>
    bool IsAvailable(IServiceProvider services);
}

IRouterTransportPlugin

Interface for router transport plugins:

namespace StellaOps.Router.Common.Plugins;

public interface IRouterTransportPlugin
{
    string TransportName { get; }
    string DisplayName { get; }
    bool IsAvailable(IServiceProvider services);
    void Register(RouterTransportRegistrationContext context);
}

IConcielierConnector

Interface for vulnerability data connectors:

namespace StellaOps.Concelier.Core.Plugins;

public interface IConcielierConnector : IAvailabilityPlugin
{
    string ConnectorId { get; }
    string DisplayName { get; }
    Task<IAsyncEnumerable<VulnerabilityRecord>> FetchAsync(
        FetchOptions options,
        CancellationToken cancellationToken);
}

IScannerAnalyzerPlugin

Interface for language-specific scanners:

namespace StellaOps.Scanner.Core.Plugins;

public interface IScannerAnalyzerPlugin : IAvailabilityPlugin
{
    string AnalyzerId { get; }
    string Language { get; }
    IEnumerable<string> SupportedFilePatterns { get; }
    Task<AnalysisResult> AnalyzeAsync(
        AnalysisContext context,
        CancellationToken cancellationToken);
}

Creating a Plugin

1. Create Project

mkdir MyCompany.StellaOps.Plugin.MyPlugin
cd MyCompany.StellaOps.Plugin.MyPlugin
dotnet new classlib -f net10.0

2. Add Package References

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
  <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
  <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
  <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="path/to/StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>

3. Create Options Class

namespace MyCompany.StellaOps.Plugin.MyPlugin;

public sealed class MyPluginOptions
{
    public string? ApiEndpoint { get; set; }
    public string? ApiKey { get; set; }
    public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(1);
    public int MaxRetries { get; set; } = 3;
}

4. Create Plugin Implementation

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;

namespace MyCompany.StellaOps.Plugin.MyPlugin;

public sealed class MyPlugin : IAvailabilityPlugin
{
    public string PluginId => "mycompany.stellaops.myplugin";
    public string DisplayName => "My Custom Plugin";

    public bool IsAvailable(IServiceProvider services)
    {
        // Check if required dependencies are available
        var config = services.GetService<IConfiguration>();
        var apiKey = config?["MyPlugin:ApiKey"];
        return !string.IsNullOrEmpty(apiKey);
    }

    public void Register(PluginRegistrationContext context)
    {
        var services = context.Services;
        var configuration = context.Configuration;

        // Bind configuration
        var section = configuration.GetSection("MyPlugin");
        services.Configure<MyPluginOptions>(options =>
        {
            section.Bind(options);
        });

        // Register services
        services.AddSingleton<MyPluginService>();
    }
}

5. Add Version Attribute

using StellaOps.Plugin.Versioning;

[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0")]

Registration Context

Generic Context

public sealed class PluginRegistrationContext
{
    public IServiceCollection Services { get; }
    public IConfiguration Configuration { get; }
}

Router Transport Context

public sealed class RouterTransportRegistrationContext
{
    public IServiceCollection Services { get; }
    public IConfiguration Configuration { get; }
    public RouterTransportMode Mode { get; }
    public string? ConfigurationSection { get; init; }
}

[Flags]
public enum RouterTransportMode
{
    None = 0,
    Server = 1,
    Client = 2,
    Both = Server | Client
}

Configuration Binding

Basic Binding

public void Register(PluginRegistrationContext context)
{
    context.Services.Configure<MyOptions>(options =>
    {
        context.Configuration.GetSection("MyPlugin").Bind(options);
    });
}

With Validation

public void Register(PluginRegistrationContext context)
{
    context.Services.AddOptions<MyOptions>()
        .Bind(context.Configuration.GetSection("MyPlugin"))
        .ValidateDataAnnotations()
        .ValidateOnStart();
}

Options Class with Validation

using System.ComponentModel.DataAnnotations;

public sealed class MyOptions
{
    [Required]
    [Url]
    public string? ApiEndpoint { get; set; }

    [Required]
    public string? ApiKey { get; set; }

    [Range(1, 100)]
    public int MaxRetries { get; set; } = 3;
}

DI Registration Patterns

Singleton Services

public void Register(PluginRegistrationContext context)
{
    context.Services.AddSingleton<IMyService, MyService>();
}

Factory Registration

public void Register(PluginRegistrationContext context)
{
    context.Services.AddSingleton<IMyService>(sp =>
    {
        var options = sp.GetRequiredService<IOptions<MyOptions>>();
        var logger = sp.GetRequiredService<ILogger<MyService>>();
        return new MyService(options.Value, logger);
    });
}

Keyed Services

public void Register(PluginRegistrationContext context)
{
    context.Services.AddKeyedSingleton<IMyService>("myplugin", (sp, key) =>
    {
        return new MyService();
    });
}

Logging

Structured Logging

public sealed class MyPluginService
{
    private readonly ILogger<MyPluginService> _logger;

    public MyPluginService(ILogger<MyPluginService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessAsync(string itemId)
    {
        using var scope = _logger.BeginScope(
            new Dictionary<string, object>
            {
                ["PluginId"] = "myplugin",
                ["ItemId"] = itemId
            });

        _logger.LogInformation("Processing item {ItemId}", itemId);

        try
        {
            // Process...
            _logger.LogDebug("Item {ItemId} processed successfully", itemId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process item {ItemId}", itemId);
            throw;
        }
    }
}

Error Handling

Availability Check

public bool IsAvailable(IServiceProvider services)
{
    try
    {
        var config = services.GetRequiredService<IConfiguration>();
        var options = config.GetSection("MyPlugin").Get<MyOptions>();

        if (string.IsNullOrEmpty(options?.ApiEndpoint))
        {
            return false;
        }

        // Check connectivity if needed
        return true;
    }
    catch (Exception ex)
    {
        _logger?.LogWarning(ex, "Availability check failed");
        return false;
    }
}

Graceful Degradation

public async Task<Result> ProcessAsync(Request request)
{
    try
    {
        return await ProcessInternalAsync(request);
    }
    catch (ApiException ex) when (ex.StatusCode == 429)
    {
        _logger.LogWarning("Rate limited, using cached data");
        return await GetCachedResultAsync(request);
    }
    catch (TimeoutException)
    {
        _logger.LogWarning("Request timeout, using fallback");
        return Result.Fallback();
    }
}

Testing

Unit Testing

public class MyPluginTests
{
    [Fact]
    public void IsAvailable_WithValidConfig_ReturnsTrue()
    {
        // Arrange
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["MyPlugin:ApiEndpoint"] = "https://api.example.com",
                ["MyPlugin:ApiKey"] = "test-key"
            })
            .Build();

        var services = new ServiceCollection();
        services.AddSingleton<IConfiguration>(config);
        var provider = services.BuildServiceProvider();

        var plugin = new MyPlugin();

        // Act
        var available = plugin.IsAvailable(provider);

        // Assert
        Assert.True(available);
    }

    [Fact]
    public void Register_AddsServices()
    {
        // Arrange
        var config = new ConfigurationBuilder().Build();
        var services = new ServiceCollection();
        var context = new PluginRegistrationContext(services, config);

        var plugin = new MyPlugin();

        // Act
        plugin.Register(context);

        // Assert
        var provider = services.BuildServiceProvider();
        Assert.NotNull(provider.GetService<MyPluginService>());
    }
}

Integration Testing

public class MyPluginIntegrationTests : IClassFixture<PluginTestFixture>
{
    private readonly PluginTestFixture _fixture;

    public MyPluginIntegrationTests(PluginTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task ProcessAsync_WithValidInput_ReturnsResult()
    {
        // Arrange
        var service = _fixture.GetService<IMyService>();
        var request = new Request { Id = "test-001" };

        // Act
        var result = await service.ProcessAsync(request);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("test-001", result.RequestId);
    }
}

Packaging

Project Configuration

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>

  <!-- Package metadata -->
  <PackageId>MyCompany.StellaOps.Plugin.MyPlugin</PackageId>
  <Version>1.0.0</Version>
  <Authors>My Company</Authors>
  <Description>My custom StellaOps plugin</Description>

  <!-- Plugin output -->
  <IsPackable>true</IsPackable>
  <IncludeBuildOutput>true</IncludeBuildOutput>
</PropertyGroup>

Plugin Manifest

Create plugin.json:

{
  "schemaVersion": "2.0",
  "id": "mycompany.stellaops.myplugin",
  "name": "My Custom Plugin",
  "version": "1.0.0",
  "assembly": {
    "path": "MyCompany.StellaOps.Plugin.MyPlugin.dll",
    "entryType": "MyCompany.StellaOps.Plugin.MyPlugin.MyPlugin"
  },
  "capabilities": ["custom-feature"],
  "platforms": ["linux-x64", "win-x64", "osx-arm64"],
  "enabled": true,
  "priority": 100
}

Build and Package

# Build release
dotnet build -c Release

# Create NuGet package
dotnet pack -c Release

# Publish to plugins directory
dotnet publish -c Release -o ./plugins

Signing

Sign with Cosign

# Generate key pair
cosign generate-key-pair

# Sign plugin assembly
cosign sign-blob --key cosign.key \
  plugins/MyPlugin.dll \
  --output-file plugins/MyPlugin.dll.sig

Verify Signature

cosign verify-blob --key cosign.pub \
  --signature plugins/MyPlugin.dll.sig \
  plugins/MyPlugin.dll

Best Practices

Determinism

  • Use stable ordering for collections
  • Use UTC timestamps in ISO-8601 format
  • Avoid non-deterministic random values
  • Cache results consistently

Offline Support

  • Don't hardcode external URLs
  • Provide fallback mechanisms
  • Cache remote data when possible
  • Log warnings, not errors, for optional network features

Performance

  • Use async/await properly
  • Implement cancellation tokens
  • Pool expensive resources
  • Limit memory allocations

Security

  • Validate all inputs
  • Sanitize output data
  • Use secure defaults
  • Never log secrets

Examples

See the plugin templates for complete working examples.

See Also