Harden runtime HTTP transport lifecycles

This commit is contained in:
master
2026-04-05 23:52:14 +03:00
parent 1151c30e3a
commit 751546084e
44 changed files with 1173 additions and 136 deletions

View File

@@ -0,0 +1,137 @@
# Sprint 20260405-011 - Transport Pooling And Attribution Hardening
## Topic & Scope
- Standardize runtime transport client lifecycle and attribution so Stella Ops services stop producing anonymous or churn-heavy long-lived connections.
- Extend the shared PostgreSQL infrastructure first, then patch the known PostgreSQL and Valkey runtime hotspots to use named, reusable clients.
- Continue the hardening pass through the first HTTP lifecycle hotspots where service-owned runtime code still allocated raw `HttpClient` instances.
- Add static guardrails and focused tests so raw runtime transport construction does not re-enter the codebase unnoticed.
- Working directory: `src/__Libraries/`.
- Expected evidence: shared infrastructure tests, targeted service/runtime validation, updated transport/database docs, and sprint-linked before/after findings.
## Dependencies & Concurrency
- Depends on `docs/implplan/SPRINT_20260405_008_Integrations_consul_pg_router_runtime_tuning.md` for the PostgreSQL runtime logging baseline.
- Depends on `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md` for the proven AdvisoryAI regression pattern and remediation baseline.
- Cross-module edits allowed for `src/AdvisoryAI/**`, `src/Attestor/**`, `src/Authority/**`, `src/BinaryIndex/**`, `src/Concelier/**`, `src/Doctor/**`, `src/EvidenceLocker/**`, `src/Findings/**`, `src/Graph/**`, `src/Integrations/**`, `src/JobEngine/**`, `src/Notify/**`, `src/Platform/**`, `src/Policy/**`, `src/ReachGraph/**`, `src/ReleaseOrchestrator/**`, `src/Scanner/**`, `src/Signals/**`, `src/Timeline/**`, `src/Router/**`, `src/Plugin/**`, `docs/**`, and `devops/**` when they consume the shared transport conventions.
## Documentation Prerequisites
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/db/RULES.md`
- `src/__Libraries/AGENTS.md`
- `src/__Libraries/StellaOps.Infrastructure.Postgres/AGENTS.md`
- `src/__Tests/AGENTS.md`
## Delivery Tracker
### XPORT-STD-001 - Extend shared PostgreSQL transport policy
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Add stable application-name support and complete pooling policy propagation to the shared PostgreSQL options/base infrastructure so module-level data sources can be named and tuned without ad hoc code.
- Update the shared library docs and tests so the behavior is explicit and regression-safe.
Completion criteria:
- [x] Shared PostgreSQL options expose stable runtime application-name configuration.
- [x] Shared data-source construction applies application name plus the full pooling policy, including idle lifetime.
- [x] Infrastructure.Postgres tests cover the new policy behavior.
### XPORT-RUNTIME-002 - Patch runtime PostgreSQL callers and service bootstraps
Status: DONE
Dependency: XPORT-STD-001
Owners: Developer
Task description:
- Convert the currently known runtime hotspots and service bootstraps to named, reusable PostgreSQL data sources instead of anonymous or ad hoc construction.
- Prioritize the services already identified in live runtime evidence: Findings, JobEngine, EvidenceLocker, AdvisoryAI/OpsMemory, ReachGraph, and Scanner reachability paths.
Completion criteria:
- [x] Touched runtime services stop constructing anonymous PostgreSQL data sources in their steady-state code paths.
- [x] Hot-path repositories touched by this sprint use reusable data sources/providers instead of raw connection strings where practical.
- [x] Compose/runtime-facing defaults or docs are updated when a touched service gains a new attribution/pooling option.
### XPORT-GUARD-003 - Add static guardrails for runtime transport construction
Status: DONE
Dependency: XPORT-STD-001
Owners: Developer
Task description:
- Add a focused convention test that scans runtime code for forbidden raw transport construction patterns and documents the allowlisted exceptions (tests, migrations, CLI setup, one-shot diagnostics).
- Cover PostgreSQL first, then include the agreed non-PostgreSQL transport patterns where the current implementation can enforce them deterministically.
Completion criteria:
- [x] A deterministic test fails on forbidden runtime transport construction patterns outside the allowlist.
- [x] The allowlist is explicit and narrow.
- [x] The guardrail is documented in the relevant shared docs/sprint notes.
### XPORT-VALKEY-004 - Stamp runtime Valkey client identity and extend guardrails
Status: DONE
Dependency: XPORT-GUARD-003
Owners: Developer
Task description:
- Stamp stable `ClientName` defaults across the runtime Valkey/Redis multiplexer construction paths that were still anonymous in service code or shared queue/cache transport helpers.
- Extend the shared convention test so unnamed runtime `ConnectionMultiplexer.Connect(...)` / `ConnectAsync(...)` usage fails outside explicit CLI/tooling/test exceptions.
Completion criteria:
- [x] Touched runtime Valkey/Redis multiplexer paths stamp stable client identity before connecting.
- [x] The shared convention suite fails on unnamed runtime Valkey multiplexer construction outside a narrow allowlist.
- [x] Shared transport rules and touched module task boards reference the new Valkey attribution standard.
### XPORT-HTTP-005 - Remove raw runtime HttpClient allocation from first-wave host paths
Status: DONE
Dependency: XPORT-GUARD-003
Owners: Developer
Task description:
- Patch the known host-owned HTTP lifecycle hotspots so they no longer allocate ad hoc `HttpClient` instances in steady-state runtime paths.
- Prefer named `IHttpClientFactory` clients where the host owns DI, and use compatibility-safe shared fallbacks only where the current plugin/controller seam still cannot require factory-backed construction.
Completion criteria:
- [x] Platform identity-provider connection tests use a named factory-backed client with no raw fallback allocation.
- [x] Attestor TrustRepo online/offline registrations resolve TUF HTTP via a named factory-backed client.
- [x] Shared HTTP hotspot regression coverage and docs capture the first hardening wave without claiming repo-wide HTTP enforcement.
### XPORT-HTTP-006 - Extend HTTP lifecycle hardening through plugin seams and legacy connector wrappers
Status: DONE
Dependency: XPORT-HTTP-005
Owners: Developer
Task description:
- Make the Integrations plugin loading seam DI-aware so built-in connector plugins can consume factory-backed runtime clients without reflection-only constructor limits.
- Patch the next HTTP hotspot wave across Integrations feed/object mirror plugins, ReleaseOrchestrator legacy vault/registry connectors, and OCI helper fallbacks so runtime code no longer allocates per-call or ad hoc `HttpClient` instances along those paths.
Completion criteria:
- [x] Integration plugin loading supports service-provider-backed activation for runtime plugins while preserving no-DI compatibility.
- [x] Integrations built-in feed/object plugins use factory-backed or shared compatibility clients instead of raw per-call `HttpClient` construction.
- [x] Legacy ReleaseOrchestrator token/auth helper paths and OCI fallback helpers move onto shared compatibility clients, and the shared hotspot convention test covers the touched files.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-04-05 | Sprint created to turn the AdvisoryAI pooling fix into a repo-wide transport hardening pass across shared PostgreSQL infrastructure, runtime callers, and static guardrails. | Developer |
| 2026-04-05 | Added shared PostgreSQL application-name policy, patched the first runtime caller wave (JobEngine, EvidenceLocker, Platform, AdvisoryAI/OpsMemory, ReachGraph, Scanner, Router transport, Plugin registry, VexLens, Findings, ExportCenter, Replay), and added convention coverage for anonymous runtime data-source creation. | Developer |
| 2026-04-05 | Validation: `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` (79/79 under Microsoft.Testing.Platform) plus targeted `dotnet build` runs for JobEngine.WebService, EvidenceLocker.Infrastructure, Scanner.Reachability, Platform.WebService, OpsMemory.WebService, ReachGraph.WebService, ExportCenter.Infrastructure, Replay.WebService, RiskEngine.Infrastructure, and ReleaseOrchestrator.PolicyGate all passed. | Developer |
| 2026-04-05 | Patched the second PostgreSQL runtime wave (Attestor Watchlist/Persistence/Rekor checkpoint store, BinaryIndex.Validation, Concelier.ProofService.Postgres, Doctor.WebService report storage, and Graph saved views) to use named reusable data sources and extended the convention test to fail on raw runtime `NpgsqlConnection` outside an explicit allowlist. | Developer |
| 2026-04-05 | Validation: targeted `dotnet build` runs for Attestor.Watchlist, Attestor.Persistence, Attestor.Core, BinaryIndex.Validation, Concelier.ProofService.Postgres, Doctor.WebService, and Graph.Api all passed; `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed `80/80`. | Developer |
| 2026-04-05 | Patched the runtime Valkey wave across Signals, BinaryIndex, ReachGraph, Attestor, Platform, Authority, Policy, JobEngine Scheduler, Scanner queue/cache/webservice paths, Notify queue paths, Timeline indexer, Router Valkey transport/gateway rate limiting, and Concelier cache so steady-state multiplexer construction stamps stable `ClientName` values. | Developer |
| 2026-04-05 | Validation: targeted `dotnet build` runs for Signals, BinaryIndex.WebService, ReachGraph.WebService, Attestor.Infrastructure, Platform.WebService, Authority, Policy.Engine, Scheduler.Queue, Scheduler.WebService, Scanner.Queue, Scanner.CallGraph, Scanner.WebService, Notify.Queue, TimelineIndexer.Infrastructure, Messaging.Transport.Valkey, Router.Gateway, and Concelier.Cache.Valkey all passed; `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed `81/81`. | Developer |
| 2026-04-05 | Patched the first HTTP lifecycle wave across Platform identity-provider probing, Attestor TrustRepo online/offline TUF registration, shared Artifact HTTP fetch, Integrations Vault client wiring, and the S3-compatible integration plugin fallback so these host-owned paths no longer allocate ad hoc runtime `HttpClient` instances. | Developer |
| 2026-04-05 | Validation: `dotnet build src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj` and `dotnet build src/__Libraries/StellaOps.Artifact.Core/StellaOps.Artifact.Core.csproj` passed; `dotnet test src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/StellaOps.Attestor.TrustRepo.Tests.csproj` passed `21/21`; `dotnet test src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj` passed `17/17`; `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed `82/82`. A full `dotnet test src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj` run completed with two unrelated existing failures in `SeedEndpointsTests.SeedDemo_WhenAuthorizationFails_ReturnsForbidden` and `QuotaEndpointsTests.Quotas_ReturnDeterministicOrder`; the new identity-provider HTTP wiring compiled and ran inside that assembly pass. | Developer |
| 2026-04-05 | Patched the second HTTP lifecycle wave by making the shared plugin loader service-provider aware, moving Integrations feed/object built-ins onto named/shared compatibility HTTP clients, routing ReleaseOrchestrator legacy vault/registry connectors through shared compatibility wrappers, and replacing raw OCI fallback client allocation in Verdict and TrustVerdict helpers. | Developer |
| 2026-04-05 | Validation: `dotnet build src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj`, `dotnet build src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/StellaOps.ReleaseOrchestrator.IntegrationHub.csproj`, and `dotnet build src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj` passed; `dotnet test src/Integrations/__Tests/StellaOps.Integrations.Tests/StellaOps.Integrations.Tests.csproj` passed with the new DI-aware plugin loader coverage; `dotnet test src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict.Tests/StellaOps.Attestor.TrustVerdict.Tests.csproj` passed; `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed with the expanded HTTP hotspot allowlist. | Developer |
## Decisions & Risks
- The first implementation wave standardizes PostgreSQL fully and applies the same lifecycle/attribution rule to other transports only where the existing runtime code already exposes a shared construction seam.
- Tests, migrations, CLI setup, and one-shot admin checks are not treated as runtime transport violations unless they share code with steady-state service paths.
- Cross-module service patches will be kept minimal and tied back to the shared standard rather than introducing per-service bespoke option models where the shared library can carry the behavior.
- The static guardrail now enforces anonymous `NpgsqlDataSource.Create(...)`, unnamed `NpgsqlDataSourceBuilder`, and raw runtime `NpgsqlConnection` usage outside an explicit allowlist.
- The Valkey convention guardrail now also fails unnamed runtime `ConnectionMultiplexer.Connect(...)` / `ConnectAsync(...)` call sites outside explicit CLI/tooling/test exceptions.
- The first shared HTTP guardrail is intentionally narrow: it covers the known host-owned hotspot files patched in this sprint, while broader repo-wide HTTP enforcement remains a follow-up because several legacy connectors and tools still create transport-specific temporary clients.
- Integrations now activates connector plugins through DI when a service provider is available, which lets built-in runtime plugins consume named factory-backed clients without breaking reflection-only callers that still rely on default construction.
- ReleaseOrchestrator legacy connectors still do not use `IHttpClientFactory`; this sprint moves them onto a shared-handler compatibility wrapper so token/auth flows stop allocating temporary clients while preserving the current plugin contract.
- The remaining explicit raw-connection allowlist is intentionally narrow: CLI/setup, migrations, diagnostics, `PlatformMigrationAdminService`, and `Workflow`'s PostgreSQL store. `Workflow` remains allowlisted because `src/Workflow/AGENTS.md` is missing, which blocks implementer-side runtime edits under the repo contract.
- Shared Valkey factories that do not receive a service-specific name now apply a module-level fallback `ClientName`; this restores baseline attribution, but Router transport callers may still want a future option for per-service Valkey identity.
- Shared transport rules are documented in `docs/technical/runtime-transport-client-rules.md`.
- HTTP compatibility fallbacks now live behind module-specific wrappers (`Integrations` shared defaults, `ReleaseOrchestrator` shared-handler connector clients, OCI helper shared clients) so hotspot files no longer construct raw clients directly; broader HTTP sweeps should continue to replace the remaining wrappers with true host-managed factories where possible.
## Next Checkpoints
- Start the next transport hardening wave with the blocked `Workflow` PostgreSQL store once the module adds `AGENTS.md`, then continue the remaining broader HTTP/SCM/Vault-style lifecycle sweep (ReleaseOrchestrator SCM/cloud connectors, any remaining tool-specific temporary clients, and factory adoption for the compatibility wrappers added here) with the same guardrail approach.

View File

@@ -0,0 +1,26 @@
# Runtime Transport Client Rules
This document defines the minimum lifecycle and attribution rules for long-lived runtime transport clients in Stella Ops services.
## PostgreSQL
- Steady-state runtime code must use named reusable `NpgsqlDataSource` instances.
- Runtime connection strings must carry stable `ApplicationName` values.
- Raw `new NpgsqlConnection(...)` is reserved for explicit CLI/setup, migration, or diagnostic exceptions.
## Valkey / Redis
- Steady-state runtime `ConnectionMultiplexer` construction must stamp a stable `ClientName`.
- Runtime code should build `ConfigurationOptions`, apply client identity, and then connect.
- Shared factories may provide a module-level default `ClientName` when the caller does not supply one.
- CLI/setup tooling, smoke tools, and test fixtures are allowed exceptions when they are explicitly allowlisted in convention tests.
## HTTP
- Runtime code should use `IHttpClientFactory`, typed clients, or module-specific wrappers instead of ad hoc `new HttpClient()`.
- When DI-backed wiring is not available yet, compatibility fallbacks must still avoid per-request or per-call `new HttpClient()` churn.
- Plugin loaders that activate runtime components should use service-provider-backed construction when available so named clients and other shared transports can flow into plugins.
- Existing analyzer-based guardrails remain in place for specialized modules, and the shared convention suite now covers the scoped host-owned HTTP hotspot waves across Integrations, ReleaseOrchestrator connector helpers, and OCI fallback publishers.
## Static Enforcement
- `src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs` enforces the shared PostgreSQL and Valkey runtime construction rules plus the scoped HTTP hotspot regression checks.
## Operational Goal
- Every long-lived runtime transport should be attributable in production diagnostics without relying on IP-only correlation.

View File

@@ -4,5 +4,6 @@ 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`: TrustRepo online/offline registration now uses named factory-backed TUF HTTP clients. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/StellaOps.Attestor.TrustRepo.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -14,6 +14,11 @@ public sealed record TrustRepoOptions
/// </summary>
public const string SectionName = "Attestor:TrustRepo";
/// <summary>
/// Named HttpClient used for TUF metadata fetches.
/// </summary>
public const string HttpClientName = "StellaOps.Attestor.TrustRepo.Tuf";
/// <summary>
/// Whether TUF-based trust distribution is enabled.
/// </summary>

View File

@@ -31,6 +31,8 @@ public static partial class TrustRepoServiceCollectionExtensions
}
});
RegisterTufHttpClient(services);
services.TryAddSingleton<ITufMetadataStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value;
@@ -47,7 +49,7 @@ public static partial class TrustRepoServiceCollectionExtensions
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
var logger = sp.GetRequiredService<ILogger<TufClient>>();
var httpClient = new HttpClient();
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient(TrustRepoOptions.HttpClientName);
return new TufClient(store, verifier, httpClient, options, logger);
});

View File

@@ -27,6 +27,8 @@ public static partial class TrustRepoServiceCollectionExtensions
services.Configure(configureOptions);
}
RegisterTufHttpClient(services);
services.AddOptions<TrustRepoOptions>()
.Validate(options =>
{
@@ -49,7 +51,7 @@ public static partial class TrustRepoServiceCollectionExtensions
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
var logger = sp.GetRequiredService<ILogger<TufClient>>();
var httpClient = new HttpClient { Timeout = options.Value.HttpTimeout };
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient(TrustRepoOptions.HttpClientName);
return new TufClient(store, verifier, httpClient, options, logger);
});
@@ -79,4 +81,12 @@ public static partial class TrustRepoServiceCollectionExtensions
logger);
});
}
private static void RegisterTufHttpClient(IServiceCollection services)
{
services.AddHttpClient(TrustRepoOptions.HttpClientName, static (sp, client) =>
{
client.Timeout = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value.HttpTimeout;
});
}
}

View File

@@ -32,7 +32,7 @@ public sealed partial class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = httpClient ?? new HttpClient();
_httpClient = httpClient ?? TrustVerdictOciRuntimeHttpClient.SharedClient;
_timeProvider = timeProvider ?? TimeProvider.System;
}
}

View File

@@ -0,0 +1,19 @@
using System.Net.Http.Headers;
namespace StellaOps.Attestor.TrustVerdict.Oci;
internal static class TrustVerdictOciRuntimeHttpClient
{
public static HttpClient SharedClient { get; } = CreateClient();
private static HttpClient CreateClient()
{
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(100)
};
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
return client;
}
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: TrustVerdict OCI attacher fallback HTTP path now reuses a shared runtime client instead of allocating ad hoc transport instances. |
| AUDIT-0067-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0067-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0067-A | TODO | Reopened after revalidation 2026-01-06. |

View File

@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260405_011-XPORT-HTTP-T | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: added TrustRepo DI tests for named TUF HTTP client registration in online/offline paths. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/StellaOps.Attestor.TrustRepo.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,39 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Attestor.TrustRepo.Tests;
public sealed class TrustRepoServiceCollectionExtensionsTests
{
[Fact]
public void AddTrustRepo_RegistersNamedTufHttpClient()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddTrustRepo();
using var provider = services.BuildServiceProvider();
var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName);
provider.GetRequiredService<ITufClient>().Should().NotBeNull();
client.Timeout.Should().Be(TimeSpan.FromSeconds(30));
}
[Fact]
public void AddTrustRepoOffline_RegistersNamedTufHttpClient()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddTrustRepoOffline();
using var provider = services.BuildServiceProvider();
var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName);
provider.GetRequiredService<ITufClient>().Should().NotBeNull();
client.Timeout.Should().Be(TimeSpan.FromSeconds(30));
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>();

View File

@@ -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. |

View File

@@ -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);

View File

@@ -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. |

View File

@@ -205,6 +205,11 @@ builder.Services.AddHttpClient("HarborFixture", client =>
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddHttpClient(IdentityProviderManagementService.HttpClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
@@ -276,8 +281,18 @@ builder.Services.AddSingleton<StellaOps.Signals.UnifiedScore.Replay.IReplayVerif
// Score history persistence store
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
{
builder.Services.AddSingleton(
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
builder.Services.AddSingleton(sp =>
{
var connectionStringBuilder = new Npgsql.NpgsqlConnectionStringBuilder(bootstrapOptions.Storage.PostgresConnectionString)
{
ApplicationName = "stellaops-platform",
};
return new Npgsql.NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString)
{
Name = "StellaOps.Platform"
}.Build();
});
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
@@ -318,7 +333,12 @@ var redisCs = builder.Configuration["ConnectionStrings:Redis"];
if (!string.IsNullOrWhiteSpace(redisCs))
{
builder.Services.AddSingleton<IConnectionMultiplexer>(
sp => ConnectionMultiplexer.Connect(redisCs));
sp =>
{
var redisOptions = ConfigurationOptions.Parse(redisCs);
redisOptions.ClientName ??= "stellaops-platform";
return ConnectionMultiplexer.Connect(redisOptions);
});
}
builder.Services.AddHostedService<EnvironmentSettingsRefreshService>();

View File

@@ -14,6 +14,8 @@ namespace StellaOps.Platform.WebService.Services;
public sealed class IdentityProviderManagementService
{
public const string HttpClientName = "PlatformIdentityProviderTest";
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
{
"standard", "ldap", "saml", "oidc"
@@ -35,7 +37,7 @@ public sealed class IdentityProviderManagementService
["standard"] = []
};
private readonly IHttpClientFactory? _httpClientFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<IdentityProviderManagementService> _logger;
// In-memory store keyed by (tenantId, id)
@@ -44,10 +46,10 @@ public sealed class IdentityProviderManagementService
public IdentityProviderManagementService(
ILogger<IdentityProviderManagementService> logger,
IHttpClientFactory? httpClientFactory = null)
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public Task<IReadOnlyList<IdentityProviderConfigDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
@@ -391,28 +393,20 @@ public sealed class IdentityProviderManagementService
}
var sw = Stopwatch.StartNew();
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
try
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
{
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
}
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
}
finally
{
if (_httpClientFactory is null)
httpClient.Dispose();
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
}
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
}
private async Task<TestConnectionResult> TestOidcConnectionAsync(
@@ -423,29 +417,21 @@ public sealed class IdentityProviderManagementService
var discoveryUrl = authority.TrimEnd('/') + "/.well-known/openid-configuration";
var sw = Stopwatch.StartNew();
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
try
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
// Basic validation: should contain issuer field
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
// Basic validation: should contain issuer field
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
{
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
}
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
}
finally
{
if (_httpClientFactory is null)
httpClient.Dispose();
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
}
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
}
private static IdentityProviderConfigDto MapToDto(IdentityProviderConfigEntry entry)

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform runtime PostgreSQL data sources for score-history and analytics ingestion/query paths. |
| SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform Valkey client construction. |
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform identity-provider HTTP test client wiring and removed raw fallback `HttpClient` allocation. |
| SPRINT_20260222_051-MGC-12 | DONE | Added `/api/v1/admin/migrations/{modules,status,verify,run}` endpoints with `platform.setup.admin` authorization and server-side migration execution wired to the platform-owned registry in `StellaOps.Platform.Database`. |
| SPRINT_20260222_051-MGC-12-SOURCES | DONE | Platform migration admin service now executes and verifies migrations across per-service plugin source sets, applies synthesized per-plugin consolidated migration on empty history with legacy history backfill, and auto-heals partial backfill states before per-source execution. |
| SPRINT_20260221_043-PLATFORM-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: fix seed endpoint authorization policy wiring and return structured non-500 error responses for expected failures. |

View File

@@ -0,0 +1,64 @@
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class IdentityProviderManagementServiceTests
{
[Theory]
[InlineData("saml", "idpMetadataUrl", "https://idp.example.com/metadata", "<EntityDescriptor xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"></EntityDescriptor>", "https://idp.example.com/metadata")]
[InlineData("oidc", "authority", "https://idp.example.com", "{\"issuer\":\"https://idp.example.com\"}", "https://idp.example.com/.well-known/openid-configuration")]
public async Task TestConnectionAsync_UsesNamedHttpClientForMetadataFetch(
string providerType,
string configKey,
string configValue,
string responseBody,
string expectedRequestUri)
{
var factory = Substitute.For<IHttpClientFactory>();
var handler = new StubHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
});
factory.CreateClient(IdentityProviderManagementService.HttpClientName)
.Returns(new HttpClient(handler));
var service = new IdentityProviderManagementService(
NullLogger<IdentityProviderManagementService>.Instance,
factory);
var result = await service.TestConnectionAsync(
new TestConnectionRequest(
providerType,
new Dictionary<string, string?> { [configKey] = configValue }),
TestContext.Current.CancellationToken);
Assert.True(result.Success);
Assert.Equal(expectedRequestUri, handler.LastRequestUri);
factory.Received(1).CreateClient(IdentityProviderManagementService.HttpClientName);
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
public string? LastRequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestUri = request.RequestUri?.ToString();
return Task.FromResult(_responseFactory(request));
}
}
}

View File

@@ -26,3 +26,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. |
| SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). |
| SPRINT_20260305_005-PLATFORM-BOUND-002 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and disallow foreign persistence references outside explicit migration/seed allowlist files. |
| SPRINT_20260405_011-XPORT-HTTP-T | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: added direct `IdentityProviderManagementService` HTTP-factory tests for OIDC/SAML connection probing. |

View File

@@ -0,0 +1,32 @@
using System.Net;
using System.Net.Http.Headers;
namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
internal static class ConnectorHttpClients
{
private static readonly SocketsHttpHandler SharedHandler = new()
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
};
public static HttpClient SharedClient { get; } = CreateClient();
public static HttpClient CreateClient(Uri? baseAddress = null)
{
var client = new HttpClient(SharedHandler, disposeHandler: false)
{
Timeout = TimeSpan.FromSeconds(100)
};
if (baseAddress is not null)
{
client.BaseAddress = baseAddress;
}
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
return client;
}
}

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -361,13 +361,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
_registryHost = $"{registryName}.azurecr.io";
_registryUrl = $"https://{_registryHost}";
_httpClient = new HttpClient
{
BaseAddress = new Uri(_registryUrl + "/")
};
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_registryUrl + "/"));
}
// Check if we need to refresh token
@@ -450,8 +444,6 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
string clientSecret,
CancellationToken ct)
{
using var tempClient = new HttpClient();
// Get Azure AD token
var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var tokenRequest = new Dictionary<string, string>
@@ -463,7 +455,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
};
using var tokenContent = new FormUrlEncodedContent(tokenRequest);
using var tokenResponse = await tempClient.PostAsync(tokenEndpoint, tokenContent, ct);
using var tokenResponse = await ConnectorHttpClients.SharedClient.PostAsync(tokenEndpoint, tokenContent, ct);
tokenResponse.EnsureSuccessStatusCode();
var aadToken = await tokenResponse.Content.ReadFromJsonAsync<AzureAdTokenResponse>(ct);
@@ -478,7 +470,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
};
using var exchangeContent = new FormUrlEncodedContent(exchangeRequest);
using var exchangeResponse = await tempClient.PostAsync(exchangeEndpoint, exchangeContent, ct);
using var exchangeResponse = await ConnectorHttpClients.SharedClient.PostAsync(exchangeEndpoint, exchangeContent, ct);
exchangeResponse.EnsureSuccessStatusCode();
var refreshToken = await exchangeResponse.Content.ReadFromJsonAsync<AcrRefreshTokenResponse>(ct);
@@ -494,7 +486,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
};
using var accessContent = new FormUrlEncodedContent(accessRequest);
using var accessResponse = await tempClient.PostAsync(accessEndpoint, accessContent, ct);
using var accessResponse = await ConnectorHttpClients.SharedClient.PostAsync(accessEndpoint, accessContent, ct);
accessResponse.EnsureSuccessStatusCode();
var accessToken = await accessResponse.Content.ReadFromJsonAsync<AcrAccessTokenResponse>(ct);

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -343,9 +343,7 @@ public sealed class DockerHubConnector : IRegistryConnectorCapability, IDisposab
}
}
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient();
}
private async Task<string> GetAuthTokenAsync(string repository, string scope, CancellationToken ct)

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -393,13 +393,8 @@ public sealed class EcrConnector : IRegistryConnectorCapability, IDisposable
}
}
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_ecrApiClient = new HttpClient();
_ecrApiClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient();
_ecrApiClient = ConnectorHttpClients.CreateClient();
}
private async Task EnsureValidTokenAsync(CancellationToken ct)

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -365,13 +365,7 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
_serviceAccountKey = JsonSerializer.Deserialize<GcpServiceAccountKey>(keyJson);
}
_httpClient = new HttpClient
{
BaseAddress = new Uri(_registryUrl + "/")
};
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_registryUrl + "/"));
}
private async Task EnsureValidTokenAsync(CancellationToken ct)
@@ -394,7 +388,6 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
var jwt = CreateServiceAccountJwt(now);
// Exchange JWT for access token
using var tempClient = new HttpClient();
var tokenRequest = new Dictionary<string, string>
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
@@ -402,7 +395,7 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
};
using var content = new FormUrlEncodedContent(tokenRequest);
using var response = await tempClient.PostAsync(
using var response = await ConnectorHttpClients.SharedClient.PostAsync(
"https://oauth2.googleapis.com/token", content, ct);
response.EnsureSuccessStatusCode();

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -240,9 +240,7 @@ public sealed class AwsSecretsManagerConnector : IVaultConnectorCapability, IDis
}
}
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient();
}
private async Task<JsonElement> CallSecretsManagerApiAsync(

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -261,13 +261,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
}
}
_httpClient = new HttpClient
{
BaseAddress = new Uri(_vaultUrl + "/")
};
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_vaultUrl + "/"));
}
private async Task EnsureValidTokenAsync(CancellationToken ct)
@@ -295,8 +289,6 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
private async Task AuthenticateManagedIdentityAsync(CancellationToken ct)
{
using var tempClient = new HttpClient();
// Azure IMDS endpoint for managed identity
var url = "http://169.254.169.254/metadata/identity/oauth2/token" +
"?api-version=2018-02-01" +
@@ -305,7 +297,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Metadata", "true");
using var response = await tempClient.SendAsync(request, ct);
using var response = await ConnectorHttpClients.SharedClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AzureTokenResponse>(ct);
@@ -316,8 +308,6 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
private async Task AuthenticateServicePrincipalAsync(CancellationToken ct)
{
using var tempClient = new HttpClient();
var tokenEndpoint = $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token";
var tokenRequest = new Dictionary<string, string>
{
@@ -328,7 +318,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
};
using var content = new FormUrlEncodedContent(tokenRequest);
using var response = await tempClient.PostAsync(tokenEndpoint, content, ct);
using var response = await ConnectorHttpClients.SharedClient.PostAsync(tokenEndpoint, content, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AzureTokenResponse>(ct);

View File

@@ -1,4 +1,4 @@
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
using System.Diagnostics;
@@ -279,14 +279,8 @@ public sealed class HashiCorpVaultConnector : IVaultConnectorCapability, IDispos
_ => throw new InvalidOperationException($"Unknown auth method: {authMethod}")
};
_httpClient = new HttpClient
{
BaseAddress = new Uri(_vaultAddress + "/")
};
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_vaultAddress + "/"));
_httpClient.DefaultRequestHeaders.Add("X-Vault-Token", _token);
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("StellaOps", "1.0"));
return _httpClient;
}
@@ -343,10 +337,9 @@ public sealed class HashiCorpVaultConnector : IVaultConnectorCapability, IDispos
}
}
using var tempClient = new HttpClient();
var loginPayload = new { role_id = roleId, secret_id = secretId };
using var response = await tempClient.PostAsJsonAsync(
using var response = await ConnectorHttpClients.SharedClient.PostAsJsonAsync(
$"{_vaultAddress}/v1/auth/approle/login", loginPayload, ct);
response.EnsureSuccessStatusCode();
@@ -373,10 +366,9 @@ public sealed class HashiCorpVaultConnector : IVaultConnectorCapability, IDispos
var jwt = await File.ReadAllTextAsync(saTokenPath, ct);
using var tempClient = new HttpClient();
var loginPayload = new { jwt, role };
using var response = await tempClient.PostAsJsonAsync(
using var response = await ConnectorHttpClients.SharedClient.PostAsJsonAsync(
$"{_vaultAddress}/v1/auth/kubernetes/login", loginPayload, ct);
response.EnsureSuccessStatusCode();

View File

@@ -4,5 +4,6 @@ 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`: legacy vault/registry connectors now use shared-handler compatibility HTTP clients instead of raw temporary `HttpClient` allocation. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/StellaOps.ReleaseOrchestrator.IntegrationHub.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -16,8 +16,7 @@ public sealed partial class ArtifactController
{
_logger.LogDebug("Fetching from HTTP: {Uri}", uri);
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
var httpClient = _httpClientFactory?.CreateClient(HttpFetchClientName) ?? SharedHttpFetchClient;
try
{

View File

@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using StellaOps.Artifact.Core;
using System.Net.Http;
namespace StellaOps.Artifact.Api;
@@ -22,15 +23,23 @@ namespace StellaOps.Artifact.Api;
public sealed partial class ArtifactController : ControllerBase
{
private const string GetArtifactActionName = "GetArtifact";
private const string HttpFetchClientName = "ArtifactController.FetchHttp";
private static readonly HttpClient SharedHttpFetchClient = new()
{
Timeout = TimeSpan.FromSeconds(30)
};
private readonly IArtifactStore _artifactStore;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly ILogger<ArtifactController> _logger;
public ArtifactController(
IArtifactStore artifactStore,
ILogger<ArtifactController> logger)
ILogger<ArtifactController> logger,
IHttpClientFactory? httpClientFactory = null)
{
_artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClientFactory = httpClientFactory;
}
}

View File

@@ -4,5 +4,6 @@ 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`: artifact HTTP fetch path now prefers factory-backed clients and uses a shared fallback instead of allocating per request. |
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Artifact.Core/StellaOps.Artifact.Core.md. Tests: `dotnet test src/__Libraries/StellaOps.Artifact.Core.Tests/StellaOps.Artifact.Core.Tests.csproj` (23 tests, MTP0001 warning). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin.Hosting;
using System;
using System.Collections.Generic;
@@ -120,6 +120,12 @@ public static class PluginLoader
{
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
where TPlugin : class
=> LoadPlugins<TPlugin>(assemblies, serviceProvider: null);
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(
IEnumerable<Assembly> assemblies,
IServiceProvider? serviceProvider)
where TPlugin : class
{
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
@@ -140,7 +146,7 @@ public static class PluginLoader
continue;
}
if (candidate.GetConstructor(Type.EmptyTypes) is null)
if (serviceProvider is null && candidate.GetConstructor(Type.EmptyTypes) is null)
{
continue;
}
@@ -148,11 +154,13 @@ public static class PluginLoader
TPlugin? plugin;
try
{
plugin = Activator.CreateInstance(candidate) as TPlugin;
plugin = serviceProvider is null
? Activator.CreateInstance(candidate) as TPlugin
: ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, candidate) as TPlugin;
}
catch
{
// Skip plugins that cannot be created via default constructor.
// Skip plugins that cannot be created via the available DI/default constructor path.
continue;
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0095-T | DONE | Revalidated 2026-01-08; test coverage audit for StellaOps.Plugin. |
| AUDIT-0095-A | TODO | Revalidated 2026-01-08 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: PluginLoader now supports service-provider-backed runtime activation so host-owned plugins can consume shared transport clients. |

View File

@@ -122,7 +122,7 @@ public sealed class OciAttestationPublisher : IOciAttestationPublisher
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpClient = httpClient ?? new HttpClient();
_httpClient = httpClient ?? OciRuntimeHttpClient.SharedClient;
_timeProvider = timeProvider ?? TimeProvider.System;
}

View File

@@ -0,0 +1,19 @@
using System.Net.Http.Headers;
namespace StellaOps.Verdict.Oci;
internal static class OciRuntimeHttpClient
{
public static HttpClient SharedClient { get; } = CreateClient();
private static HttpClient CreateClient()
{
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(100)
};
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
return client;
}
}

View File

@@ -9,5 +9,6 @@ Source of truth: `docs/implplan/SPRINT_20260222_080_Verdict_persistence_dal_to_e
| VERDICT-EF-03 | DONE | PostgresVerdictStore converted to use VerdictDataSource + VerdictDbContextFactory pattern; inline VerdictDbContext removed. |
| VERDICT-EF-04 | DONE | Compiled model stubs generated; assembly attributes excluded from compilation; VerdictDbContextFactory uses compiled model for default schema. |
| VERDICT-EF-05 | DONE | Sequential builds pass (0 warnings, 0 errors); module docs and AGENTS.md updated; sprint tracker updated. |
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: OCI attestation publisher fallback HTTP path now reuses a shared runtime client instead of allocating ad hoc transport instances. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,167 @@
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Infrastructure.Postgres.Tests;
[Trait("Category", TestCategories.Unit)]
public sealed class RuntimePostgresConstructionConventionTests
{
private static readonly string RepoRoot = FindRepoRoot();
private static readonly HashSet<string> AllowedRuntimeRawConnectionFiles = new(StringComparer.Ordinal)
{
"src/Cli/__Libraries/StellaOps.Cli.Plugins.Aoc/AocVerificationService.cs",
"src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/DatabaseSetupStep.cs",
"src/Cli/StellaOps.Cli/Services/MigrationCommandService.cs",
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresConnectionPoolCheck.cs",
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresConnectivityCheck.cs",
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresMigrationStatusCheck.cs",
"src/Platform/StellaOps.Platform.WebService/Services/PlatformMigrationAdminService.cs",
"src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs",
"src/__Libraries/StellaOps.Doctor.Plugins.Database/Checks/DatabaseCheckBase.cs",
"src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs",
"src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs",
};
private static readonly HashSet<string> AllowedRuntimeValkeyConnectionFiles = new(StringComparer.Ordinal)
{
"src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/CacheSetupStep.cs",
"src/Tools/NotifySmokeCheck/NotifySmokeCheckRunner.cs",
"src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs",
};
private static readonly string[] KnownHttpLifecycleHotspots =
[
"src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs",
"src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs",
"src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs",
"src/Integrations/StellaOps.Integrations.WebService/FeedMirrorConnectorPlugins.cs",
"src/Integrations/StellaOps.Integrations.WebService/ObjectStorageConnectorPlugins.cs",
"src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs",
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs",
"src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs",
"src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs",
];
[Fact]
public void Runtime_code_does_not_use_anonymous_NpgsqlDataSource_Create()
{
var offenders = EnumerateRuntimeSourceFiles()
.Where(file => File.ReadAllText(file).Contains("NpgsqlDataSource.Create(", StringComparison.Ordinal))
.Select(ToRelativePath)
.ToList();
offenders.Should().BeEmpty(
"runtime PostgreSQL callers should use a named data source or the shared PostgreSQL policy path");
}
[Fact]
public void Runtime_data_source_builders_must_apply_runtime_attribution()
{
var offenders = EnumerateRuntimeSourceFiles()
.Where(file =>
{
var content = File.ReadAllText(file);
return content.Contains("new NpgsqlDataSourceBuilder(", StringComparison.Ordinal)
&& !content.Contains("ApplicationName", StringComparison.Ordinal)
&& !content.Contains("PostgresConnectionStringPolicy", StringComparison.Ordinal);
})
.Select(ToRelativePath)
.ToList();
offenders.Should().BeEmpty(
"runtime NpgsqlDataSourceBuilder call sites must stamp ApplicationName explicitly or flow through PostgresConnectionStringPolicy");
}
[Fact]
public void Runtime_code_does_not_use_raw_NpgsqlConnection_outside_allowlist()
{
var offenders = EnumerateRuntimeSourceFiles()
.Where(file => File.ReadAllText(file).Contains("new NpgsqlConnection(", StringComparison.Ordinal))
.Select(ToRelativePath)
.Where(file => !AllowedRuntimeRawConnectionFiles.Contains(file))
.ToList();
offenders.Should().BeEmpty(
"runtime PostgreSQL callers should use named data sources unless they are an explicit CLI/setup/migration/diagnostic exception");
}
[Fact]
public void Runtime_valkey_connections_must_stamp_client_name()
{
var offenders = EnumerateRuntimeSourceFiles()
.Where(file =>
{
var content = File.ReadAllText(file);
var relativePath = ToRelativePath(file);
var usesValkeyMultiplexer =
content.Contains("ConnectionMultiplexer.Connect(", StringComparison.Ordinal)
|| content.Contains("ConnectionMultiplexer.ConnectAsync(", StringComparison.Ordinal);
if (!usesValkeyMultiplexer || AllowedRuntimeValkeyConnectionFiles.Contains(relativePath))
{
return false;
}
return !content.Contains("ClientName", StringComparison.Ordinal);
})
.Select(ToRelativePath)
.ToList();
offenders.Should().BeEmpty(
"runtime Valkey callers should stamp a stable ClientName unless they are an explicit CLI/tooling exception");
}
[Fact]
public void Known_runtime_http_hotspots_do_not_allocate_ad_hoc_HttpClient()
{
var offenders = EnumerateRuntimeSourceFiles()
.Where(file => KnownHttpLifecycleHotspots.Contains(ToRelativePath(file)))
.Where(file => File.ReadAllText(file).Contains("new HttpClient(", StringComparison.Ordinal))
.Select(ToRelativePath)
.ToList();
offenders.Should().BeEmpty(
"the scoped HTTP hardening waves removed raw runtime HttpClient allocation from the known host-owned hotspots");
}
private static IEnumerable<string> EnumerateRuntimeSourceFiles()
{
var srcRoot = Path.Combine(RepoRoot, "src");
return Directory.EnumerateFiles(srcRoot, "*.cs", SearchOption.AllDirectories)
.Where(file => !PathSegments(file).Any(segment => segment.EndsWith(".Tests", StringComparison.Ordinal)))
.Where(file => !PathSegments(file).Any(segment => segment.EndsWith(".Benchmarks", StringComparison.Ordinal)))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}Testing{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}__Tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal));
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var srcPath = Path.Combine(current.FullName, "src");
var docsPath = Path.Combine(current.FullName, "docs");
if (Directory.Exists(srcPath) && Directory.Exists(docsPath))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Failed to locate the Stella Ops repository root from the test output directory.");
}
private static string ToRelativePath(string fullPath)
=> Path.GetRelativePath(RepoRoot, fullPath).Replace('\\', '/');
private static IEnumerable<string> PathSegments(string fullPath)
=> fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260405_011-XPORT-GUARD | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: shared PostgreSQL/Valkey transport policy tests and static guardrails for runtime transport construction plus the scoped second-wave HTTP hotspot regressions. |
| AUDIT-0028-M | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
| AUDIT-0028-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
| AUDIT-0028-A | DONE | Waived (test project; revalidated 2026-01-08). |