diff --git a/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md b/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md new file mode 100644 index 000000000..2c74958ef --- /dev/null +++ b/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md @@ -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. diff --git a/docs/technical/runtime-transport-client-rules.md b/docs/technical/runtime-transport-client-rules.md new file mode 100644 index 000000000..c3e955e40 --- /dev/null +++ b/docs/technical/runtime-transport-client-rules.md @@ -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. diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TASKS.md b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TASKS.md index d050e8fed..dd4acd309 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TASKS.md +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TASKS.md @@ -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. | diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoOptions.cs b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoOptions.cs index d9497d725..bbc91edf0 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoOptions.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoOptions.cs @@ -14,6 +14,11 @@ public sealed record TrustRepoOptions /// public const string SectionName = "Attestor:TrustRepo"; + /// + /// Named HttpClient used for TUF metadata fetches. + /// + public const string HttpClientName = "StellaOps.Attestor.TrustRepo.Tuf"; + /// /// Whether TUF-based trust distribution is enabled. /// diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs index fda26bb2d..f24c6bba7 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs @@ -31,6 +31,8 @@ public static partial class TrustRepoServiceCollectionExtensions } }); + RegisterTufHttpClient(services); + services.TryAddSingleton(sp => { var options = sp.GetRequiredService>().Value; @@ -47,7 +49,7 @@ public static partial class TrustRepoServiceCollectionExtensions var verifier = sp.GetRequiredService(); var options = sp.GetRequiredService>(); var logger = sp.GetRequiredService>(); - var httpClient = new HttpClient(); + var httpClient = sp.GetRequiredService().CreateClient(TrustRepoOptions.HttpClientName); return new TufClient(store, verifier, httpClient, options, logger); }); diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs index 70f4e3b64..138302f6c 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs @@ -27,6 +27,8 @@ public static partial class TrustRepoServiceCollectionExtensions services.Configure(configureOptions); } + RegisterTufHttpClient(services); + services.AddOptions() .Validate(options => { @@ -49,7 +51,7 @@ public static partial class TrustRepoServiceCollectionExtensions var verifier = sp.GetRequiredService(); var options = sp.GetRequiredService>(); var logger = sp.GetRequiredService>(); - var httpClient = new HttpClient { Timeout = options.Value.HttpTimeout }; + var httpClient = sp.GetRequiredService().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>().Value.HttpTimeout; + }); + } } diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs index 1b3dd2eed..baf7f3a60 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs @@ -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; } } diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciRuntimeHttpClient.cs b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciRuntimeHttpClient.cs new file mode 100644 index 000000000..06aea85ad --- /dev/null +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciRuntimeHttpClient.cs @@ -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; + } +} diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/TASKS.md b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/TASKS.md index a04754262..35116f951 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/TASKS.md +++ b/src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/TASKS.md @@ -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. | diff --git a/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TASKS.md b/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TASKS.md index b102e8fd2..ef8d875b7 100644 --- a/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TASKS.md +++ b/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TASKS.md @@ -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. | diff --git a/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TrustRepoServiceCollectionExtensionsTests.cs b/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TrustRepoServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..a6d365c03 --- /dev/null +++ b/src/Attestor/__Libraries/__Tests/StellaOps.Attestor.TrustRepo.Tests/TrustRepoServiceCollectionExtensionsTests.cs @@ -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(); + var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName); + + provider.GetRequiredService().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(); + var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName); + + provider.GetRequiredService().Should().NotBeNull(); + client.Timeout.Should().Be(TimeSpan.FromSeconds(30)); + } +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/FeedMirrorConnectorPlugins.cs b/src/Integrations/StellaOps.Integrations.WebService/FeedMirrorConnectorPlugins.cs new file mode 100644 index 000000000..6bd54d2c7 --- /dev/null +++ b/src/Integrations/StellaOps.Integrations.WebService/FeedMirrorConnectorPlugins.cs @@ -0,0 +1,190 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Net.Http.Headers; + +namespace StellaOps.Integrations.WebService; + +/// +/// 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. +/// +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 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 + { + ["endpoint"] = config.Endpoint, + ["provider"] = Provider.ToString() + }, + Duration: duration) + : new TestConnectionResult( + Success: false, + Message: $"Feed mirror returned {response.StatusCode}", + Details: new Dictionary + { + ["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 + { + ["endpoint"] = config.Endpoint, + ["error"] = ex.GetType().Name + }, + Duration: duration); + } + } + + public async Task 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 + { + ["provider"] = Provider.ToString() + }, + CheckedAt: _timeProvider.GetUtcNow(), + Duration: duration) + : new HealthCheckResult( + Status: HealthStatus.Unhealthy, + Message: $"Feed mirror returned {response.StatusCode}", + Details: new Dictionary + { + ["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 + { + ["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; +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs b/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs index 06dd069e0..e5f02fbc5 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Infrastructure/DefaultImplementations.cs @@ -58,18 +58,18 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger /// In production, integrate with Authority service. /// URI format: authref://vault/{path}#{key} /// -public sealed class StubAuthRefResolver : IAuthRefResolver +public sealed class VaultAuthRefResolver : IAuthRefResolver { - private readonly ILogger _logger; + public const string HttpClientName = "VaultClient"; + + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; - private readonly string _vaultAddr; private readonly string _vaultToken; - public StubAuthRefResolver(ILogger logger, IHttpClientFactory httpClientFactory) + public VaultAuthRefResolver(ILogger 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); diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationHttpClientDefaults.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationHttpClientDefaults.cs new file mode 100644 index 000000000..33b2f70f8 --- /dev/null +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationHttpClientDefaults.cs @@ -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; + } +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationPluginLoader.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationPluginLoader.cs index 0ece8db31..7d8626221 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationPluginLoader.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationPluginLoader.cs @@ -14,11 +14,15 @@ namespace StellaOps.Integrations.WebService; public sealed class IntegrationPluginLoader { private readonly ILogger? _logger; + private readonly IServiceProvider? _serviceProvider; private readonly List _plugins = []; - public IntegrationPluginLoader(ILogger? logger = null) + public IntegrationPluginLoader( + ILogger? logger = null, + IServiceProvider? serviceProvider = null) { _logger = logger; + _serviceProvider = serviceProvider; } /// @@ -26,6 +30,29 @@ public sealed class IntegrationPluginLoader /// public IReadOnlyList Plugins => _plugins; + /// + /// Registers a plugin instance directly. + /// Primarily used by tests and deterministic in-process setups. + /// + public void Register(IIntegrationConnectorPlugin plugin) + { + ArgumentNullException.ThrowIfNull(plugin); + _plugins.Add(plugin); + } + + /// + /// Registers plugin instances directly. + /// + public void RegisterRange(IEnumerable plugins) + { + ArgumentNullException.ThrowIfNull(plugins); + + foreach (var plugin in plugins) + { + Register(plugin); + } + } + /// /// Discovers and loads integration connector plugins from the specified directory. /// @@ -52,7 +79,9 @@ public sealed class IntegrationPluginLoader foreach (var pluginAssembly in result.Plugins) { - var connectorPlugins = PluginLoader.LoadPlugins(new[] { pluginAssembly.Assembly }); + var connectorPlugins = PluginLoader.LoadPlugins( + [pluginAssembly.Assembly], + _serviceProvider); loadedPlugins.AddRange(connectorPlugins); foreach (var plugin in connectorPlugins) @@ -71,7 +100,7 @@ public sealed class IntegrationPluginLoader /// public IReadOnlyList LoadFromAssemblies(IEnumerable assemblies) { - var loadedPlugins = PluginLoader.LoadPlugins(assemblies); + var loadedPlugins = PluginLoader.LoadPlugins(assemblies, _serviceProvider); _plugins.AddRange(loadedPlugins); return loadedPlugins; } @@ -92,6 +121,14 @@ public sealed class IntegrationPluginLoader return _plugins.Where(p => p.Type == type).ToList(); } + /// + /// Gets a discovery-capable plugin by provider. + /// + public IIntegrationDiscoveryPlugin? GetDiscoveryByProvider(IntegrationProvider provider) + { + return GetByProvider(provider) as IIntegrationDiscoveryPlugin; + } + /// /// Gets all available plugins (checking IsAvailable). /// diff --git a/src/Integrations/StellaOps.Integrations.WebService/ObjectStorageConnectorPlugins.cs b/src/Integrations/StellaOps.Integrations.WebService/ObjectStorageConnectorPlugins.cs new file mode 100644 index 000000000..87e7c278b --- /dev/null +++ b/src/Integrations/StellaOps.Integrations.WebService/ObjectStorageConnectorPlugins.cs @@ -0,0 +1,137 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; + +namespace StellaOps.Integrations.WebService; + +/// +/// Minimal S3-compatible storage connector used for local MinIO and other health-probeable object stores. +/// +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 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 + { + ["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 + { + ["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint, + ["statusCode"] = ((int)response.StatusCode).ToString() + }, + duration); + } + catch (Exception ex) + { + return new TestConnectionResult( + false, + ex.Message, + new Dictionary + { + ["probeUri"] = BuildProbeUri(config.Endpoint).ToString() + }, + _timeProvider.GetElapsedTime(startedAt)); + } + } + + public async Task 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 + { + ["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 + { + ["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; +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/Program.cs b/src/Integrations/StellaOps.Integrations.WebService/Program.cs index 51ba9ddad..f090ce380 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Program.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Program.cs @@ -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(); // 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(sp => { var logger = sp.GetRequiredService>(); - var loader = new IntegrationPluginLoader(logger); + var loader = new IntegrationPluginLoader(logger, sp); // Load from plugins directory var pluginsDir = builder.Configuration.GetValue("Integrations:PluginsDirectory") @@ -97,7 +113,7 @@ builder.Services.AddSingleton(TimeProvider.System); // Infrastructure builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Core service builder.Services.AddScoped(); diff --git a/src/Integrations/StellaOps.Integrations.WebService/TASKS.md b/src/Integrations/StellaOps.Integrations.WebService/TASKS.md index b7709c637..b148e1de7 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/TASKS.md +++ b/src/Integrations/StellaOps.Integrations.WebService/TASKS.md @@ -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. | diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPluginLoaderTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPluginLoaderTests.cs index 4388e825b..1f1e1f398 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPluginLoaderTests.cs +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPluginLoaderTests.cs @@ -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.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.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.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.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 SupportedResourceTypes => [IntegrationDiscoveryResourceTypes.Repositories]; + + public Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero)); + } + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } + + public Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default) + { + return Task.FromResult>([]); + } + } } + +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 TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => Task.FromResult(new TestConnectionResult(true, Name, null, TimeSpan.Zero)); + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, Name, null, DateTimeOffset.UtcNow, TimeSpan.Zero)); +} + +public sealed record LoaderDependency(string Name); diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/TASKS.md b/src/Integrations/__Tests/StellaOps.Integrations.Tests/TASKS.md index e383f67a7..228a5a83c 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/TASKS.md +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/TASKS.md @@ -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. | diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index 1609b24ce..cff7a2613 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -276,8 +281,18 @@ builder.Services.AddSingleton + { + var connectionStringBuilder = new Npgsql.NpgsqlConnectionStringBuilder(bootstrapOptions.Storage.PostgresConnectionString) + { + ApplicationName = "stellaops-platform", + }; + + return new Npgsql.NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString) + { + Name = "StellaOps.Platform" + }.Build(); + }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -318,7 +333,12 @@ var redisCs = builder.Configuration["ConnectionStrings:Redis"]; if (!string.IsNullOrWhiteSpace(redisCs)) { builder.Services.AddSingleton( - sp => ConnectionMultiplexer.Connect(redisCs)); + sp => + { + var redisOptions = ConfigurationOptions.Parse(redisCs); + redisOptions.ClientName ??= "stellaops-platform"; + return ConnectionMultiplexer.Connect(redisOptions); + }); } builder.Services.AddHostedService(); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs b/src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs index 9d2ec5c00..dc0f0d152 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs @@ -14,6 +14,8 @@ namespace StellaOps.Platform.WebService.Services; public sealed class IdentityProviderManagementService { + public const string HttpClientName = "PlatformIdentityProviderTest"; + private static readonly HashSet 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 _logger; // In-memory store keyed by (tenantId, id) @@ -44,10 +46,10 @@ public sealed class IdentityProviderManagementService public IdentityProviderManagementService( ILogger logger, - IHttpClientFactory? httpClientFactory = null) + IHttpClientFactory httpClientFactory) { _logger = logger; - _httpClientFactory = httpClientFactory; + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); } public Task> 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 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) diff --git a/src/Platform/StellaOps.Platform.WebService/TASKS.md b/src/Platform/StellaOps.Platform.WebService/TASKS.md index c95b5b0a0..cf3b4e79a 100644 --- a/src/Platform/StellaOps.Platform.WebService/TASKS.md +++ b/src/Platform/StellaOps.Platform.WebService/TASKS.md @@ -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. | diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/IdentityProviderManagementServiceTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/IdentityProviderManagementServiceTests.cs new file mode 100644 index 000000000..7849b35b6 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/IdentityProviderManagementServiceTests.cs @@ -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", "", "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(); + 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.Instance, + factory); + + var result = await service.TestConnectionAsync( + new TestConnectionRequest( + providerType, + new Dictionary { [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 _responseFactory; + + public StubHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + public string? LastRequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri?.ToString(); + return Task.FromResult(_responseFactory(request)); + } + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md index 4a9dfffa8..23d0a725b 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md @@ -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. | diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/ConnectorHttpClients.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/ConnectorHttpClients.cs new file mode 100644 index 000000000..70d321ab1 --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/ConnectorHttpClients.cs @@ -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; + } +} diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs index 3792e241e..22b6c689a 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs @@ -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 @@ -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(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(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(ct); diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs index 41ea389c0..423252931 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs @@ -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 GetAuthTokenAsync(string repository, string scope, CancellationToken ct) diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs index bb6d38bf1..f8853baa1 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs @@ -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) diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs index d98b090fa..2d09ce8a2 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs @@ -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(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 { ["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(); diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs index 781e3a224..4e07e3559 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs @@ -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 CallSecretsManagerApiAsync( diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs index bbbfbd35b..a85b8a14f 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs @@ -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(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 { @@ -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(ct); diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs index 9b106b397..cc59a9e65 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs @@ -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(); diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md index 56adcd2d1..9b278e2ff 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md @@ -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. | diff --git a/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs b/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs index 581542d16..ff71b65ae 100644 --- a/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs +++ b/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs @@ -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 { diff --git a/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.cs b/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.cs index a9dcc7991..896881bb7 100644 --- a/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.cs +++ b/src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.cs @@ -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 _logger; public ArtifactController( IArtifactStore artifactStore, - ILogger logger) + ILogger logger, + IHttpClientFactory? httpClientFactory = null) { _artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClientFactory = httpClientFactory; } } diff --git a/src/__Libraries/StellaOps.Artifact.Core/TASKS.md b/src/__Libraries/StellaOps.Artifact.Core/TASKS.md index 70deb909b..6cf5e2b3b 100644 --- a/src/__Libraries/StellaOps.Artifact.Core/TASKS.md +++ b/src/__Libraries/StellaOps.Artifact.Core/TASKS.md @@ -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. | diff --git a/src/__Libraries/StellaOps.Plugin/PluginContracts.cs b/src/__Libraries/StellaOps.Plugin/PluginContracts.cs index 86bade49b..a8916f011 100644 --- a/src/__Libraries/StellaOps.Plugin/PluginContracts.cs +++ b/src/__Libraries/StellaOps.Plugin/PluginContracts.cs @@ -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 LoadPlugins(IEnumerable assemblies) where TPlugin : class + => LoadPlugins(assemblies, serviceProvider: null); + + public static IReadOnlyList LoadPlugins( + IEnumerable 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; } diff --git a/src/__Libraries/StellaOps.Plugin/TASKS.md b/src/__Libraries/StellaOps.Plugin/TASKS.md index e70909f52..689a8931e 100644 --- a/src/__Libraries/StellaOps.Plugin/TASKS.md +++ b/src/__Libraries/StellaOps.Plugin/TASKS.md @@ -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. | diff --git a/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs b/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs index ed59406a5..435071bb0 100644 --- a/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs +++ b/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs @@ -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; } diff --git a/src/__Libraries/StellaOps.Verdict/Oci/OciRuntimeHttpClient.cs b/src/__Libraries/StellaOps.Verdict/Oci/OciRuntimeHttpClient.cs new file mode 100644 index 000000000..e73abf05d --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Oci/OciRuntimeHttpClient.cs @@ -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; + } +} diff --git a/src/__Libraries/StellaOps.Verdict/TASKS.md b/src/__Libraries/StellaOps.Verdict/TASKS.md index 983ff5f8e..795c159ff 100644 --- a/src/__Libraries/StellaOps.Verdict/TASKS.md +++ b/src/__Libraries/StellaOps.Verdict/TASKS.md @@ -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. | diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs new file mode 100644 index 000000000..465ade1b1 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs @@ -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 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 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 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 PathSegments(string fullPath) + => fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/TASKS.md b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/TASKS.md index ef04e8dcd..1a39c099e 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/TASKS.md +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/TASKS.md @@ -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). |