Files
git.stella-ops.org/src/Concelier/StellaOps.Concelier.Plugin.Unified/FeedPluginAdapter.cs
2026-01-12 12:24:17 +02:00

381 lines
12 KiB
C#

namespace StellaOps.Concelier.Plugin.Unified;
using System.Runtime.CompilerServices;
using StellaOps.Plugin;
using StellaOps.Plugin.Abstractions;
using StellaOps.Plugin.Abstractions.Capabilities;
using StellaOps.Plugin.Abstractions.Context;
using StellaOps.Plugin.Abstractions.Health;
using StellaOps.Plugin.Abstractions.Lifecycle;
using UnifiedFeedCapability = StellaOps.Plugin.Abstractions.Capabilities.IFeedCapability;
using LegacyFeedConnector = StellaOps.Plugin.IFeedConnector;
/// <summary>
/// Adapts an existing IConnectorPlugin to the unified IPlugin and IFeedCapability interfaces.
/// This enables gradual migration of Concelier connectors to the unified plugin architecture.
/// </summary>
/// <remarks>
/// The adapter bridges the Concelier-specific IConnectorPlugin/IFeedConnector interfaces to
/// the Plugin.Abstractions IFeedCapability interface. The underlying connector operations
/// are delegated to the wrapped connector.
/// </remarks>
public sealed class FeedPluginAdapter : IPlugin, UnifiedFeedCapability
{
private readonly IConnectorPlugin _plugin;
private readonly FeedType _feedType;
private readonly IReadOnlyList<string> _ecosystems;
private IPluginContext? _context;
private IServiceProvider? _serviceProvider;
private LegacyFeedConnector? _connector;
private PluginLifecycleState _state = PluginLifecycleState.Discovered;
/// <summary>
/// Creates a new adapter for an existing connector plugin.
/// </summary>
/// <param name="plugin">The existing connector plugin to wrap.</param>
/// <param name="feedType">The feed type classification.</param>
/// <param name="ecosystems">Ecosystems covered by this feed.</param>
public FeedPluginAdapter(
IConnectorPlugin plugin,
FeedType feedType,
IReadOnlyList<string> ecosystems)
{
_plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
_feedType = feedType;
_ecosystems = ecosystems ?? Array.Empty<string>();
}
#region IPlugin
/// <inheritdoc />
public PluginInfo Info => new(
Id: $"com.stellaops.feed.{_plugin.Name}",
Name: _plugin.Name,
Version: "1.0.0",
Vendor: "Stella Ops",
Description: $"Vulnerability feed connector for {_plugin.Name}");
/// <inheritdoc />
public PluginTrustLevel TrustLevel => PluginTrustLevel.BuiltIn;
/// <inheritdoc />
public PluginCapabilities Capabilities => PluginCapabilities.Feed | PluginCapabilities.Network;
/// <inheritdoc />
public PluginLifecycleState State => _state;
/// <inheritdoc />
public async Task InitializeAsync(IPluginContext context, CancellationToken ct)
{
_context = context;
_state = PluginLifecycleState.Initializing;
try
{
// Get service provider from context
// Note: This would require IPluginContext to expose the service provider
// For now we create a minimal service provider
_serviceProvider = CreateServiceProvider(context);
if (!_plugin.IsAvailable(_serviceProvider))
{
_state = PluginLifecycleState.Failed;
throw new InvalidOperationException(
$"Feed connector '{_plugin.Name}' is not available.");
}
// Create the actual connector
_connector = _plugin.Create(_serviceProvider);
_state = PluginLifecycleState.Active;
context.Logger.Info("Feed connector adapter initialized for {FeedId}", _plugin.Name);
}
catch (Exception ex)
{
_state = PluginLifecycleState.Failed;
context.Logger.Error(ex, "Failed to initialize feed connector {FeedId}", _plugin.Name);
throw;
}
await Task.CompletedTask;
}
/// <inheritdoc />
public async Task<HealthCheckResult> HealthCheckAsync(CancellationToken ct)
{
try
{
if (_state != PluginLifecycleState.Active)
{
return HealthCheckResult.Unhealthy($"Connector is in state {_state}");
}
if (_connector == null)
{
return HealthCheckResult.Unhealthy("Connector not initialized");
}
return HealthCheckResult.Healthy()
.WithDetails(new Dictionary<string, object>
{
["feedId"] = FeedId,
["feedType"] = _feedType.ToString(),
["ecosystems"] = string.Join(", ", _ecosystems),
["sourceName"] = _connector.SourceName
});
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(ex);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (_connector is IAsyncDisposable asyncDisposable)
{
return asyncDisposable.DisposeAsync();
}
if (_connector is IDisposable disposable)
{
disposable.Dispose();
}
_connector = null;
_state = PluginLifecycleState.Stopped;
return ValueTask.CompletedTask;
}
#endregion
#region IConnectorCapability
/// <inheritdoc />
public string ConnectorType => $"feed.{_plugin.Name}";
/// <inheritdoc />
public string DisplayName => _plugin.Name;
/// <inheritdoc />
public async Task<ConnectionTestResult> TestConnectionAsync(CancellationToken ct)
{
if (_state != PluginLifecycleState.Active)
{
return ConnectionTestResult.Failed($"Connector is in state {_state}");
}
try
{
// For feed connectors, we can't easily test connection without a full fetch
// Return success if the connector is initialized
return ConnectionTestResult.Succeeded();
}
catch (Exception ex)
{
return ConnectionTestResult.Failed(ex.Message, ex);
}
}
/// <inheritdoc />
public async Task<ConnectionInfo> GetConnectionInfoAsync(CancellationToken ct)
{
return new ConnectionInfo(
EndpointUrl: $"feed://{_plugin.Name}",
AuthenticatedAs: null,
ConnectedSince: null,
Metadata: new Dictionary<string, object>
{
["feedType"] = _feedType.ToString(),
["ecosystems"] = _ecosystems
});
}
#endregion
#region IFeedCapability
/// <inheritdoc />
public string FeedId => _plugin.Name;
/// <inheritdoc />
public FeedType FeedType => _feedType;
/// <inheritdoc />
public IReadOnlyList<string> CoveredEcosystems => _ecosystems;
/// <inheritdoc />
public async Task<FeedMetadata> GetMetadataAsync(CancellationToken ct)
{
EnsureActive();
// The legacy IFeedConnector doesn't have direct metadata access
// Return basic metadata from the plugin
return new FeedMetadata(
FeedId: FeedId,
LastModified: null,
TotalEntries: null,
SchemaVersion: null,
Description: $"Vulnerability feed from {FeedId}");
}
/// <inheritdoc />
public IAsyncEnumerable<FeedEntry> FetchEntriesAsync(
DateTimeOffset? since,
CancellationToken ct)
{
EnsureActive();
// The legacy IFeedConnector uses FetchAsync/ParseAsync/MapAsync pipeline
// which doesn't directly stream entries. We document this limitation.
return EmptyAsyncEnumerable<FeedEntry>.ThrowNotSupported(
$"Direct entry streaming is not supported for {FeedId}. " +
"Use the legacy FetchAsync/ParseAsync/MapAsync pipeline via the connector directly.");
}
/// <inheritdoc />
public async Task<FeedEntry?> GetEntryAsync(string id, CancellationToken ct)
{
EnsureActive();
// The legacy connector doesn't support individual entry lookup
throw new NotSupportedException(
$"Individual entry lookup is not supported for {FeedId}. " +
"Use the legacy pipeline for bulk data processing.");
}
/// <inheritdoc />
public async Task<string?> GetRawEntryAsync(string id, CancellationToken ct)
{
EnsureActive();
// Not supported by legacy connector
return null;
}
#endregion
#region Extended Operations
/// <summary>
/// Gets the underlying connector for extended operations.
/// </summary>
public LegacyFeedConnector? Connector => _connector;
/// <summary>
/// Executes the fetch phase of the legacy pipeline.
/// </summary>
public async Task FetchAsync(CancellationToken ct)
{
EnsureActive();
await _connector!.FetchAsync(_serviceProvider!, ct);
}
/// <summary>
/// Executes the parse phase of the legacy pipeline.
/// </summary>
public async Task ParseAsync(CancellationToken ct)
{
EnsureActive();
await _connector!.ParseAsync(_serviceProvider!, ct);
}
/// <summary>
/// Executes the map phase of the legacy pipeline.
/// </summary>
public async Task MapAsync(CancellationToken ct)
{
EnsureActive();
await _connector!.MapAsync(_serviceProvider!, ct);
}
/// <summary>
/// Executes the full pipeline (fetch, parse, map).
/// </summary>
public async Task RunPipelineAsync(CancellationToken ct)
{
await FetchAsync(ct);
await ParseAsync(ct);
await MapAsync(ct);
}
#endregion
#region Helpers
private void EnsureActive()
{
if (_state != PluginLifecycleState.Active || _connector == null)
{
throw new InvalidOperationException(
$"Feed connector '{_plugin.Name}' is not active (state: {_state})");
}
}
private IServiceProvider CreateServiceProvider(IPluginContext context)
{
// The adapter needs to provide services that the legacy connector expects
// For now, return a minimal provider that wraps the context
return new MinimalServiceProvider(context);
}
private sealed class MinimalServiceProvider : IServiceProvider
{
private readonly IPluginContext _context;
public MinimalServiceProvider(IPluginContext context)
{
_context = context;
}
public object? GetService(Type serviceType)
{
if (serviceType == typeof(IPluginContext))
return _context;
if (serviceType == typeof(IPluginLogger))
return _context.Logger;
return null;
}
}
#endregion
}
/// <summary>
/// Helper for creating async enumerables that throw exceptions.
/// </summary>
internal static class EmptyAsyncEnumerable<T>
{
public static IAsyncEnumerable<T> ThrowNotSupported(string message)
{
return new ThrowingAsyncEnumerable(message);
}
private sealed class ThrowingAsyncEnumerable : IAsyncEnumerable<T>
{
private readonly string _message;
public ThrowingAsyncEnumerable(string message) => _message = message;
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken ct) =>
new ThrowingAsyncEnumerator(_message);
}
private sealed class ThrowingAsyncEnumerator : IAsyncEnumerator<T>
{
private readonly string _message;
public ThrowingAsyncEnumerator(string message) => _message = message;
public T Current => throw new NotSupportedException(_message);
public ValueTask<bool> MoveNextAsync() =>
throw new NotSupportedException(_message);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}