Harden runtime HTTP transport lifecycles
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Feed mirror providers all target the Concelier mirror surface and currently differ only by upstream feed family.
|
||||
/// These plugins expose the missing provider identities so the Integration Catalog can manage them explicitly.
|
||||
/// </summary>
|
||||
public abstract class FeedMirrorConnectorPluginBase : IIntegrationConnectorPlugin
|
||||
{
|
||||
public const string HttpClientName = "IntegrationsFeedMirrorProbe";
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
IntegrationHttpClientDefaults.CreateSharedClient(TimeSpan.FromSeconds(30));
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
protected FeedMirrorConnectorPluginBase(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public IntegrationType Type => IntegrationType.FeedMirror;
|
||||
|
||||
public abstract IntegrationProvider Provider { get; }
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = CreateHealthRequest(config);
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Feed mirror connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["provider"] = Provider.ToString()
|
||||
},
|
||||
Duration: duration)
|
||||
: new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Feed mirror returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Connection failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = CreateHealthRequest(config);
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: "Feed mirror service is healthy",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["provider"] = Provider.ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration)
|
||||
: new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Feed mirror returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Health check failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient GetHttpClient()
|
||||
=> _httpClientFactory?.CreateClient(HttpClientName) ?? SharedHttpClient;
|
||||
|
||||
private static HttpRequestMessage CreateHealthRequest(IntegrationConfig config)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, BuildHealthUri(config.Endpoint));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ResolvedSecret))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static Uri BuildHealthUri(string endpoint)
|
||||
{
|
||||
var endpointUri = new Uri(endpoint, UriKind.Absolute);
|
||||
return new Uri($"{endpointUri.GetLeftPart(UriPartial.Authority)}/health");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StellaOpsMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public StellaOpsMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "stellaops-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.StellaOpsMirror;
|
||||
}
|
||||
|
||||
public sealed class NvdMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public NvdMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "nvd-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.NvdMirror;
|
||||
}
|
||||
|
||||
public sealed class OsvMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public OsvMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "osv-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.OsvMirror;
|
||||
}
|
||||
@@ -58,18 +58,18 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger
|
||||
/// In production, integrate with Authority service.
|
||||
/// URI format: authref://vault/{path}#{key}
|
||||
/// </summary>
|
||||
public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
public sealed class VaultAuthRefResolver : IAuthRefResolver
|
||||
{
|
||||
private readonly ILogger<StubAuthRefResolver> _logger;
|
||||
public const string HttpClientName = "VaultClient";
|
||||
|
||||
private readonly ILogger<VaultAuthRefResolver> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly string _vaultAddr;
|
||||
private readonly string _vaultToken;
|
||||
|
||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
public VaultAuthRefResolver(ILogger<VaultAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR") ?? "http://vault.stella-ops.local:8200";
|
||||
_vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "stellaops-dev-root-token-2026";
|
||||
}
|
||||
|
||||
@@ -88,8 +88,7 @@ public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
var path = hashIndex >= 0 ? remainder[..hashIndex] : remainder;
|
||||
var key = hashIndex >= 0 ? remainder[(hashIndex + 1)..] : "value";
|
||||
|
||||
var client = _httpClientFactory.CreateClient("VaultClient");
|
||||
client.BaseAddress = new Uri(_vaultAddr);
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
client.DefaultRequestHeaders.Add("X-Vault-Token", _vaultToken);
|
||||
|
||||
var response = await client.GetAsync($"/v1/secret/data/{path}", cancellationToken);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
internal static class IntegrationHttpClientDefaults
|
||||
{
|
||||
public static HttpClient CreateSharedClient(TimeSpan timeout)
|
||||
{
|
||||
var client = new HttpClient
|
||||
{
|
||||
Timeout = timeout
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@ namespace StellaOps.Integrations.WebService;
|
||||
public sealed class IntegrationPluginLoader
|
||||
{
|
||||
private readonly ILogger<IntegrationPluginLoader>? _logger;
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly List<IIntegrationConnectorPlugin> _plugins = [];
|
||||
|
||||
public IntegrationPluginLoader(ILogger<IntegrationPluginLoader>? logger = null)
|
||||
public IntegrationPluginLoader(
|
||||
ILogger<IntegrationPluginLoader>? logger = null,
|
||||
IServiceProvider? serviceProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -26,6 +30,29 @@ public sealed class IntegrationPluginLoader
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> Plugins => _plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a plugin instance directly.
|
||||
/// Primarily used by tests and deterministic in-process setups.
|
||||
/// </summary>
|
||||
public void Register(IIntegrationConnectorPlugin plugin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
_plugins.Add(plugin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers plugin instances directly.
|
||||
/// </summary>
|
||||
public void RegisterRange(IEnumerable<IIntegrationConnectorPlugin> plugins)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugins);
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
Register(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and loads integration connector plugins from the specified directory.
|
||||
/// </summary>
|
||||
@@ -52,7 +79,9 @@ public sealed class IntegrationPluginLoader
|
||||
|
||||
foreach (var pluginAssembly in result.Plugins)
|
||||
{
|
||||
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(new[] { pluginAssembly.Assembly });
|
||||
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(
|
||||
[pluginAssembly.Assembly],
|
||||
_serviceProvider);
|
||||
loadedPlugins.AddRange(connectorPlugins);
|
||||
|
||||
foreach (var plugin in connectorPlugins)
|
||||
@@ -71,7 +100,7 @@ public sealed class IntegrationPluginLoader
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
|
||||
{
|
||||
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies);
|
||||
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies, _serviceProvider);
|
||||
_plugins.AddRange(loadedPlugins);
|
||||
return loadedPlugins;
|
||||
}
|
||||
@@ -92,6 +121,14 @@ public sealed class IntegrationPluginLoader
|
||||
return _plugins.Where(p => p.Type == type).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a discovery-capable plugin by provider.
|
||||
/// </summary>
|
||||
public IIntegrationDiscoveryPlugin? GetDiscoveryByProvider(IntegrationProvider provider)
|
||||
{
|
||||
return GetByProvider(provider) as IIntegrationDiscoveryPlugin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available plugins (checking IsAvailable).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal S3-compatible storage connector used for local MinIO and other health-probeable object stores.
|
||||
/// </summary>
|
||||
public sealed class S3CompatibleConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public const string HttpClientName = "IntegrationsObjectStorageProbe";
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
IntegrationHttpClientDefaults.CreateSharedClient(TimeSpan.FromSeconds(30));
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public S3CompatibleConnectorPlugin()
|
||||
: this(null, null)
|
||||
{
|
||||
}
|
||||
|
||||
public S3CompatibleConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "s3-compatible";
|
||||
|
||||
public IntegrationType Type => IntegrationType.ObjectStorage;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.S3Compatible;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = _timeProvider.GetTimestamp();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildProbeUri(config.Endpoint));
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetElapsedTime(startedAt);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
true,
|
||||
"S3-compatible storage probe succeeded.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
false,
|
||||
$"S3-compatible storage probe returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
false,
|
||||
ex.Message,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = BuildProbeUri(config.Endpoint).ToString()
|
||||
},
|
||||
_timeProvider.GetElapsedTime(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = _timeProvider.GetTimestamp();
|
||||
var checkedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildProbeUri(config.Endpoint));
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetElapsedTime(startedAt);
|
||||
var status = response.IsSuccessStatusCode ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
||||
|
||||
return new HealthCheckResult(
|
||||
status,
|
||||
response.IsSuccessStatusCode
|
||||
? "S3-compatible storage probe is healthy."
|
||||
: $"S3-compatible storage probe returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
checkedAt,
|
||||
duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HealthCheckResult(
|
||||
HealthStatus.Unhealthy,
|
||||
ex.Message,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = BuildProbeUri(config.Endpoint).ToString()
|
||||
},
|
||||
checkedAt,
|
||||
_timeProvider.GetElapsedTime(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildProbeUri(string endpoint)
|
||||
{
|
||||
var endpointUri = new Uri(endpoint, UriKind.Absolute);
|
||||
if (string.IsNullOrEmpty(endpointUri.AbsolutePath) || endpointUri.AbsolutePath == "/")
|
||||
{
|
||||
return new Uri(endpointUri, "/minio/health/live");
|
||||
}
|
||||
|
||||
return endpointUri;
|
||||
}
|
||||
|
||||
private HttpClient GetHttpClient()
|
||||
=> _httpClientFactory?.CreateClient(HttpClientName) ?? SharedHttpClient;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Router.AspNet;
|
||||
using System.Net.Http.Headers;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
@@ -55,13 +56,28 @@ builder.Services.AddStartupMigrations(
|
||||
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
||||
|
||||
// HttpClient factory (used by AuthRef resolver for Vault)
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpClient(VaultAuthRefResolver.HttpClientName, client =>
|
||||
{
|
||||
var vaultAddr = builder.Configuration["VAULT_ADDR"] ?? "http://vault.stella-ops.local:8200";
|
||||
client.BaseAddress = new Uri(vaultAddr.TrimEnd('/') + "/");
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
builder.Services.AddHttpClient(S3CompatibleConnectorPlugin.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
});
|
||||
builder.Services.AddHttpClient(FeedMirrorConnectorPluginBase.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
});
|
||||
|
||||
// Plugin loader
|
||||
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<IntegrationPluginLoader>>();
|
||||
var loader = new IntegrationPluginLoader(logger);
|
||||
var loader = new IntegrationPluginLoader(logger, sp);
|
||||
|
||||
// Load from plugins directory
|
||||
var pluginsDir = builder.Configuration.GetValue<string>("Integrations:PluginsDirectory")
|
||||
@@ -97,7 +113,7 @@ builder.Services.AddSingleton(TimeProvider.System);
|
||||
// Infrastructure
|
||||
builder.Services.AddScoped<IIntegrationEventPublisher, LoggingEventPublisher>();
|
||||
builder.Services.AddScoped<IIntegrationAuditLogger, LoggingAuditLogger>();
|
||||
builder.Services.AddScoped<IAuthRefResolver, StubAuthRefResolver>();
|
||||
builder.Services.AddScoped<IAuthRefResolver, VaultAuthRefResolver>();
|
||||
|
||||
// Core service
|
||||
builder.Services.AddScoped<IntegrationService>();
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Vault auth-ref client registration, DI-aware plugin loading, and factory/shared lifecycle cleanup for the built-in feed/object connector plugins. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
using StellaOps.Integrations.Plugin.GitLab;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using Xunit;
|
||||
|
||||
@@ -77,4 +81,127 @@ public class IntegrationPluginLoaderTests
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void Register_WithDiscoveryPlugin_ExposesDiscoveryLookup()
|
||||
{
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
var plugin = new FakeDiscoveryPlugin();
|
||||
|
||||
loader.Register(plugin);
|
||||
|
||||
loader.Plugins.Should().ContainSingle();
|
||||
loader.GetByProvider(IntegrationProvider.Custom).Should().BeSameAs(plugin);
|
||||
loader.GetDiscoveryByProvider(IntegrationProvider.Custom).Should().BeSameAs(plugin);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithBuiltInAssemblies_LoadsAliasProviders()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(S3CompatibleConnectorPlugin.HttpClientName);
|
||||
services.AddHttpClient(FeedMirrorConnectorPluginBase.HttpClientName);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance, serviceProvider);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies(
|
||||
[
|
||||
typeof(Program).Assembly,
|
||||
typeof(GitLabConnectorPlugin).Assembly,
|
||||
typeof(DockerRegistryConnectorPlugin).Assembly
|
||||
]);
|
||||
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.GitLabCi && plugin.Type == IntegrationType.CiCd);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.GitLabContainerRegistry && plugin.Type == IntegrationType.Registry);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.S3Compatible && plugin.Type == IntegrationType.ObjectStorage);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.StellaOpsMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.NvdMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.OsvMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithServiceProvider_LoadsPluginsThatRequireDependencyInjection()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(new LoaderDependency("di"))
|
||||
.BuildServiceProvider();
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance, services);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies([typeof(ServiceProviderOnlyPlugin).Assembly]);
|
||||
|
||||
loaded.Should().ContainSingle(plugin => plugin.Provider == IntegrationProvider.Bitbucket);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithoutServiceProvider_SkipsPluginsThatRequireDependencyInjection()
|
||||
{
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies([typeof(ServiceProviderOnlyPlugin).Assembly]);
|
||||
|
||||
loaded.Should().NotContain(plugin => plugin.Provider == IntegrationProvider.Bitbucket);
|
||||
}
|
||||
|
||||
private sealed class FakeDiscoveryPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
public string Name => "fake";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Custom;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes => [IntegrationDiscoveryResourceTypes.Repositories];
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<DiscoveredIntegrationResource>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ServiceProviderOnlyPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly LoaderDependency _dependency;
|
||||
|
||||
public ServiceProviderOnlyPlugin(LoaderDependency dependency)
|
||||
{
|
||||
_dependency = dependency;
|
||||
}
|
||||
|
||||
public string Name => $"service-provider-only-{_dependency.Name}";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Bitbucket;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new TestConnectionResult(true, Name, null, TimeSpan.Zero));
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, Name, null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public sealed record LoaderDependency(string Name);
|
||||
|
||||
@@ -8,5 +8,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_040-TESTS | DONE | Deterministic AI Code Guard run service and endpoint coverage. |
|
||||
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: DI-aware IntegrationPluginLoader regression coverage for service-provider-backed plugin activation. |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user