381 lines
12 KiB
C#
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;
|
|
}
|
|
}
|