Harden runtime HTTP transport lifecycles
This commit is contained in:
@@ -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.
|
||||
26
docs/technical/runtime-transport-client-rules.md
Normal file
26
docs/technical/runtime-transport-client-rules.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Runtime Transport Client Rules
|
||||
|
||||
This document defines the minimum lifecycle and attribution rules for long-lived runtime transport clients in Stella Ops services.
|
||||
|
||||
## PostgreSQL
|
||||
- Steady-state runtime code must use named reusable `NpgsqlDataSource` instances.
|
||||
- Runtime connection strings must carry stable `ApplicationName` values.
|
||||
- Raw `new NpgsqlConnection(...)` is reserved for explicit CLI/setup, migration, or diagnostic exceptions.
|
||||
|
||||
## Valkey / Redis
|
||||
- Steady-state runtime `ConnectionMultiplexer` construction must stamp a stable `ClientName`.
|
||||
- Runtime code should build `ConfigurationOptions`, apply client identity, and then connect.
|
||||
- Shared factories may provide a module-level default `ClientName` when the caller does not supply one.
|
||||
- CLI/setup tooling, smoke tools, and test fixtures are allowed exceptions when they are explicitly allowlisted in convention tests.
|
||||
|
||||
## HTTP
|
||||
- Runtime code should use `IHttpClientFactory`, typed clients, or module-specific wrappers instead of ad hoc `new HttpClient()`.
|
||||
- When DI-backed wiring is not available yet, compatibility fallbacks must still avoid per-request or per-call `new HttpClient()` churn.
|
||||
- Plugin loaders that activate runtime components should use service-provider-backed construction when available so named clients and other shared transports can flow into plugins.
|
||||
- Existing analyzer-based guardrails remain in place for specialized modules, and the shared convention suite now covers the scoped host-owned HTTP hotspot waves across Integrations, ReleaseOrchestrator connector helpers, and OCI fallback publishers.
|
||||
|
||||
## Static Enforcement
|
||||
- `src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs` enforces the shared PostgreSQL and Valkey runtime construction rules plus the scoped HTTP hotspot regression checks.
|
||||
|
||||
## Operational Goal
|
||||
- Every long-lived runtime transport should be attributable in production diagnostics without relying on IP-only correlation.
|
||||
@@ -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. |
|
||||
|
||||
@@ -14,6 +14,11 @@ public sealed record TrustRepoOptions
|
||||
/// </summary>
|
||||
public const string SectionName = "Attestor:TrustRepo";
|
||||
|
||||
/// <summary>
|
||||
/// Named HttpClient used for TUF metadata fetches.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "StellaOps.Attestor.TrustRepo.Tuf";
|
||||
|
||||
/// <summary>
|
||||
/// Whether TUF-based trust distribution is enabled.
|
||||
/// </summary>
|
||||
|
||||
@@ -31,6 +31,8 @@ public static partial class TrustRepoServiceCollectionExtensions
|
||||
}
|
||||
});
|
||||
|
||||
RegisterTufHttpClient(services);
|
||||
|
||||
services.TryAddSingleton<ITufMetadataStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value;
|
||||
@@ -47,7 +49,7 @@ public static partial class TrustRepoServiceCollectionExtensions
|
||||
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
|
||||
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
|
||||
var logger = sp.GetRequiredService<ILogger<TufClient>>();
|
||||
var httpClient = new HttpClient();
|
||||
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient(TrustRepoOptions.HttpClientName);
|
||||
return new TufClient(store, verifier, httpClient, options, logger);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ public static partial class TrustRepoServiceCollectionExtensions
|
||||
services.Configure(configureOptions);
|
||||
}
|
||||
|
||||
RegisterTufHttpClient(services);
|
||||
|
||||
services.AddOptions<TrustRepoOptions>()
|
||||
.Validate(options =>
|
||||
{
|
||||
@@ -49,7 +51,7 @@ public static partial class TrustRepoServiceCollectionExtensions
|
||||
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
|
||||
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
|
||||
var logger = sp.GetRequiredService<ILogger<TufClient>>();
|
||||
var httpClient = new HttpClient { Timeout = options.Value.HttpTimeout };
|
||||
var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient(TrustRepoOptions.HttpClientName);
|
||||
return new TufClient(store, verifier, httpClient, options, logger);
|
||||
});
|
||||
|
||||
@@ -79,4 +81,12 @@ public static partial class TrustRepoServiceCollectionExtensions
|
||||
logger);
|
||||
});
|
||||
}
|
||||
|
||||
private static void RegisterTufHttpClient(IServiceCollection services)
|
||||
{
|
||||
services.AddHttpClient(TrustRepoOptions.HttpClientName, static (sp, client) =>
|
||||
{
|
||||
client.Timeout = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value.HttpTimeout;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Attestor.TrustRepo.Tests;
|
||||
|
||||
public sealed class TrustRepoServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddTrustRepo_RegistersNamedTufHttpClient()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddTrustRepo();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName);
|
||||
|
||||
provider.GetRequiredService<ITufClient>().Should().NotBeNull();
|
||||
client.Timeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddTrustRepoOffline_RegistersNamedTufHttpClient()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddTrustRepoOffline();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
|
||||
var client = httpClientFactory.CreateClient(TrustRepoOptions.HttpClientName);
|
||||
|
||||
provider.GetRequiredService<ITufClient>().Should().NotBeNull();
|
||||
client.Timeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Feed mirror providers all target the Concelier mirror surface and currently differ only by upstream feed family.
|
||||
/// These plugins expose the missing provider identities so the Integration Catalog can manage them explicitly.
|
||||
/// </summary>
|
||||
public abstract class FeedMirrorConnectorPluginBase : IIntegrationConnectorPlugin
|
||||
{
|
||||
public const string HttpClientName = "IntegrationsFeedMirrorProbe";
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
IntegrationHttpClientDefaults.CreateSharedClient(TimeSpan.FromSeconds(30));
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
protected FeedMirrorConnectorPluginBase(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public IntegrationType Type => IntegrationType.FeedMirror;
|
||||
|
||||
public abstract IntegrationProvider Provider { get; }
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = CreateHealthRequest(config);
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Feed mirror connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["provider"] = Provider.ToString()
|
||||
},
|
||||
Duration: duration)
|
||||
: new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Feed mirror returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Connection failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = CreateHealthRequest(config);
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: "Feed mirror service is healthy",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["provider"] = Provider.ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration)
|
||||
: new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Feed mirror returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Health check failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient GetHttpClient()
|
||||
=> _httpClientFactory?.CreateClient(HttpClientName) ?? SharedHttpClient;
|
||||
|
||||
private static HttpRequestMessage CreateHealthRequest(IntegrationConfig config)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, BuildHealthUri(config.Endpoint));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ResolvedSecret))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static Uri BuildHealthUri(string endpoint)
|
||||
{
|
||||
var endpointUri = new Uri(endpoint, UriKind.Absolute);
|
||||
return new Uri($"{endpointUri.GetLeftPart(UriPartial.Authority)}/health");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StellaOpsMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public StellaOpsMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "stellaops-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.StellaOpsMirror;
|
||||
}
|
||||
|
||||
public sealed class NvdMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public NvdMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "nvd-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.NvdMirror;
|
||||
}
|
||||
|
||||
public sealed class OsvMirrorConnectorPlugin : FeedMirrorConnectorPluginBase
|
||||
{
|
||||
public OsvMirrorConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(httpClientFactory, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "osv-mirror";
|
||||
|
||||
public override IntegrationProvider Provider => IntegrationProvider.OsvMirror;
|
||||
}
|
||||
@@ -58,18 +58,18 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger
|
||||
/// In production, integrate with Authority service.
|
||||
/// URI format: authref://vault/{path}#{key}
|
||||
/// </summary>
|
||||
public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
public sealed class VaultAuthRefResolver : IAuthRefResolver
|
||||
{
|
||||
private readonly ILogger<StubAuthRefResolver> _logger;
|
||||
public const string HttpClientName = "VaultClient";
|
||||
|
||||
private readonly ILogger<VaultAuthRefResolver> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly string _vaultAddr;
|
||||
private readonly string _vaultToken;
|
||||
|
||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
public VaultAuthRefResolver(ILogger<VaultAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR") ?? "http://vault.stella-ops.local:8200";
|
||||
_vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "stellaops-dev-root-token-2026";
|
||||
}
|
||||
|
||||
@@ -88,8 +88,7 @@ public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
var path = hashIndex >= 0 ? remainder[..hashIndex] : remainder;
|
||||
var key = hashIndex >= 0 ? remainder[(hashIndex + 1)..] : "value";
|
||||
|
||||
var client = _httpClientFactory.CreateClient("VaultClient");
|
||||
client.BaseAddress = new Uri(_vaultAddr);
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
client.DefaultRequestHeaders.Add("X-Vault-Token", _vaultToken);
|
||||
|
||||
var response = await client.GetAsync($"/v1/secret/data/{path}", cancellationToken);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
internal static class IntegrationHttpClientDefaults
|
||||
{
|
||||
public static HttpClient CreateSharedClient(TimeSpan timeout)
|
||||
{
|
||||
var client = new HttpClient
|
||||
{
|
||||
Timeout = timeout
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@ namespace StellaOps.Integrations.WebService;
|
||||
public sealed class IntegrationPluginLoader
|
||||
{
|
||||
private readonly ILogger<IntegrationPluginLoader>? _logger;
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly List<IIntegrationConnectorPlugin> _plugins = [];
|
||||
|
||||
public IntegrationPluginLoader(ILogger<IntegrationPluginLoader>? logger = null)
|
||||
public IntegrationPluginLoader(
|
||||
ILogger<IntegrationPluginLoader>? logger = null,
|
||||
IServiceProvider? serviceProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -26,6 +30,29 @@ public sealed class IntegrationPluginLoader
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> Plugins => _plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a plugin instance directly.
|
||||
/// Primarily used by tests and deterministic in-process setups.
|
||||
/// </summary>
|
||||
public void Register(IIntegrationConnectorPlugin plugin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
_plugins.Add(plugin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers plugin instances directly.
|
||||
/// </summary>
|
||||
public void RegisterRange(IEnumerable<IIntegrationConnectorPlugin> plugins)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugins);
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
Register(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and loads integration connector plugins from the specified directory.
|
||||
/// </summary>
|
||||
@@ -52,7 +79,9 @@ public sealed class IntegrationPluginLoader
|
||||
|
||||
foreach (var pluginAssembly in result.Plugins)
|
||||
{
|
||||
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(new[] { pluginAssembly.Assembly });
|
||||
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(
|
||||
[pluginAssembly.Assembly],
|
||||
_serviceProvider);
|
||||
loadedPlugins.AddRange(connectorPlugins);
|
||||
|
||||
foreach (var plugin in connectorPlugins)
|
||||
@@ -71,7 +100,7 @@ public sealed class IntegrationPluginLoader
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
|
||||
{
|
||||
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies);
|
||||
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies, _serviceProvider);
|
||||
_plugins.AddRange(loadedPlugins);
|
||||
return loadedPlugins;
|
||||
}
|
||||
@@ -92,6 +121,14 @@ public sealed class IntegrationPluginLoader
|
||||
return _plugins.Where(p => p.Type == type).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a discovery-capable plugin by provider.
|
||||
/// </summary>
|
||||
public IIntegrationDiscoveryPlugin? GetDiscoveryByProvider(IntegrationProvider provider)
|
||||
{
|
||||
return GetByProvider(provider) as IIntegrationDiscoveryPlugin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available plugins (checking IsAvailable).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal S3-compatible storage connector used for local MinIO and other health-probeable object stores.
|
||||
/// </summary>
|
||||
public sealed class S3CompatibleConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public const string HttpClientName = "IntegrationsObjectStorageProbe";
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
IntegrationHttpClientDefaults.CreateSharedClient(TimeSpan.FromSeconds(30));
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public S3CompatibleConnectorPlugin()
|
||||
: this(null, null)
|
||||
{
|
||||
}
|
||||
|
||||
public S3CompatibleConnectorPlugin(
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "s3-compatible";
|
||||
|
||||
public IntegrationType Type => IntegrationType.ObjectStorage;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.S3Compatible;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = _timeProvider.GetTimestamp();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildProbeUri(config.Endpoint));
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetElapsedTime(startedAt);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
true,
|
||||
"S3-compatible storage probe succeeded.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
false,
|
||||
$"S3-compatible storage probe returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
false,
|
||||
ex.Message,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = BuildProbeUri(config.Endpoint).ToString()
|
||||
},
|
||||
_timeProvider.GetElapsedTime(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startedAt = _timeProvider.GetTimestamp();
|
||||
var checkedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BuildProbeUri(config.Endpoint));
|
||||
using var response = await GetHttpClient().SendAsync(request, cancellationToken);
|
||||
var duration = _timeProvider.GetElapsedTime(startedAt);
|
||||
var status = response.IsSuccessStatusCode ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
||||
|
||||
return new HealthCheckResult(
|
||||
status,
|
||||
response.IsSuccessStatusCode
|
||||
? "S3-compatible storage probe is healthy."
|
||||
: $"S3-compatible storage probe returned {(int)response.StatusCode} {response.ReasonPhrase}.",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = request.RequestUri?.ToString() ?? config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
checkedAt,
|
||||
duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HealthCheckResult(
|
||||
HealthStatus.Unhealthy,
|
||||
ex.Message,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["probeUri"] = BuildProbeUri(config.Endpoint).ToString()
|
||||
},
|
||||
checkedAt,
|
||||
_timeProvider.GetElapsedTime(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri BuildProbeUri(string endpoint)
|
||||
{
|
||||
var endpointUri = new Uri(endpoint, UriKind.Absolute);
|
||||
if (string.IsNullOrEmpty(endpointUri.AbsolutePath) || endpointUri.AbsolutePath == "/")
|
||||
{
|
||||
return new Uri(endpointUri, "/minio/health/live");
|
||||
}
|
||||
|
||||
return endpointUri;
|
||||
}
|
||||
|
||||
private HttpClient GetHttpClient()
|
||||
=> _httpClientFactory?.CreateClient(HttpClientName) ?? SharedHttpClient;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Router.AspNet;
|
||||
using System.Net.Http.Headers;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
@@ -55,13 +56,28 @@ builder.Services.AddStartupMigrations(
|
||||
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
||||
|
||||
// HttpClient factory (used by AuthRef resolver for Vault)
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpClient(VaultAuthRefResolver.HttpClientName, client =>
|
||||
{
|
||||
var vaultAddr = builder.Configuration["VAULT_ADDR"] ?? "http://vault.stella-ops.local:8200";
|
||||
client.BaseAddress = new Uri(vaultAddr.TrimEnd('/') + "/");
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
builder.Services.AddHttpClient(S3CompatibleConnectorPlugin.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
});
|
||||
builder.Services.AddHttpClient(FeedMirrorConnectorPluginBase.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
});
|
||||
|
||||
// Plugin loader
|
||||
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<IntegrationPluginLoader>>();
|
||||
var loader = new IntegrationPluginLoader(logger);
|
||||
var loader = new IntegrationPluginLoader(logger, sp);
|
||||
|
||||
// Load from plugins directory
|
||||
var pluginsDir = builder.Configuration.GetValue<string>("Integrations:PluginsDirectory")
|
||||
@@ -97,7 +113,7 @@ builder.Services.AddSingleton(TimeProvider.System);
|
||||
// Infrastructure
|
||||
builder.Services.AddScoped<IIntegrationEventPublisher, LoggingEventPublisher>();
|
||||
builder.Services.AddScoped<IIntegrationAuditLogger, LoggingAuditLogger>();
|
||||
builder.Services.AddScoped<IAuthRefResolver, StubAuthRefResolver>();
|
||||
builder.Services.AddScoped<IAuthRefResolver, VaultAuthRefResolver>();
|
||||
|
||||
// Core service
|
||||
builder.Services.AddScoped<IntegrationService>();
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Vault auth-ref client registration, DI-aware plugin loading, and factory/shared lifecycle cleanup for the built-in feed/object connector plugins. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
using StellaOps.Integrations.Plugin.GitLab;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using Xunit;
|
||||
|
||||
@@ -77,4 +81,127 @@ public class IntegrationPluginLoaderTests
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void Register_WithDiscoveryPlugin_ExposesDiscoveryLookup()
|
||||
{
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
var plugin = new FakeDiscoveryPlugin();
|
||||
|
||||
loader.Register(plugin);
|
||||
|
||||
loader.Plugins.Should().ContainSingle();
|
||||
loader.GetByProvider(IntegrationProvider.Custom).Should().BeSameAs(plugin);
|
||||
loader.GetDiscoveryByProvider(IntegrationProvider.Custom).Should().BeSameAs(plugin);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithBuiltInAssemblies_LoadsAliasProviders()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(S3CompatibleConnectorPlugin.HttpClientName);
|
||||
services.AddHttpClient(FeedMirrorConnectorPluginBase.HttpClientName);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance, serviceProvider);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies(
|
||||
[
|
||||
typeof(Program).Assembly,
|
||||
typeof(GitLabConnectorPlugin).Assembly,
|
||||
typeof(DockerRegistryConnectorPlugin).Assembly
|
||||
]);
|
||||
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.GitLabCi && plugin.Type == IntegrationType.CiCd);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.GitLabContainerRegistry && plugin.Type == IntegrationType.Registry);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.S3Compatible && plugin.Type == IntegrationType.ObjectStorage);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.StellaOpsMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.NvdMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
loaded.Should().Contain(plugin => plugin.Provider == IntegrationProvider.OsvMirror && plugin.Type == IntegrationType.FeedMirror);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithServiceProvider_LoadsPluginsThatRequireDependencyInjection()
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(new LoaderDependency("di"))
|
||||
.BuildServiceProvider();
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance, services);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies([typeof(ServiceProviderOnlyPlugin).Assembly]);
|
||||
|
||||
loaded.Should().ContainSingle(plugin => plugin.Provider == IntegrationProvider.Bitbucket);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithoutServiceProvider_SkipsPluginsThatRequireDependencyInjection()
|
||||
{
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
var loaded = loader.LoadFromAssemblies([typeof(ServiceProviderOnlyPlugin).Assembly]);
|
||||
|
||||
loaded.Should().NotContain(plugin => plugin.Provider == IntegrationProvider.Bitbucket);
|
||||
}
|
||||
|
||||
private sealed class FakeDiscoveryPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
public string Name => "fake";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Custom;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes => [IntegrationDiscoveryResourceTypes.Repositories];
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<DiscoveredIntegrationResource>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ServiceProviderOnlyPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly LoaderDependency _dependency;
|
||||
|
||||
public ServiceProviderOnlyPlugin(LoaderDependency dependency)
|
||||
{
|
||||
_dependency = dependency;
|
||||
}
|
||||
|
||||
public string Name => $"service-provider-only-{_dependency.Name}";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Bitbucket;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new TestConnectionResult(true, Name, null, TimeSpan.Zero));
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, Name, null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public sealed record LoaderDependency(string Name);
|
||||
|
||||
@@ -8,5 +8,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| SPRINT_20260208_040-TESTS | DONE | Deterministic AI Code Guard run service and endpoint coverage. |
|
||||
| SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: DI-aware IntegrationPluginLoader regression coverage for service-provider-backed plugin activation. |
|
||||
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ builder.Services.AddHttpClient("HarborFixture", client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient(IdentityProviderManagementService.HttpClientName, client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
@@ -276,8 +281,18 @@ builder.Services.AddSingleton<StellaOps.Signals.UnifiedScore.Replay.IReplayVerif
|
||||
// Score history persistence store
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton(sp =>
|
||||
{
|
||||
var connectionStringBuilder = new Npgsql.NpgsqlConnectionStringBuilder(bootstrapOptions.Storage.PostgresConnectionString)
|
||||
{
|
||||
ApplicationName = "stellaops-platform",
|
||||
};
|
||||
|
||||
return new Npgsql.NpgsqlDataSourceBuilder(connectionStringBuilder.ConnectionString)
|
||||
{
|
||||
Name = "StellaOps.Platform"
|
||||
}.Build();
|
||||
});
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
@@ -318,7 +333,12 @@ var redisCs = builder.Configuration["ConnectionStrings:Redis"];
|
||||
if (!string.IsNullOrWhiteSpace(redisCs))
|
||||
{
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(
|
||||
sp => ConnectionMultiplexer.Connect(redisCs));
|
||||
sp =>
|
||||
{
|
||||
var redisOptions = ConfigurationOptions.Parse(redisCs);
|
||||
redisOptions.ClientName ??= "stellaops-platform";
|
||||
return ConnectionMultiplexer.Connect(redisOptions);
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddHostedService<EnvironmentSettingsRefreshService>();
|
||||
|
||||
@@ -14,6 +14,8 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class IdentityProviderManagementService
|
||||
{
|
||||
public const string HttpClientName = "PlatformIdentityProviderTest";
|
||||
|
||||
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"standard", "ldap", "saml", "oidc"
|
||||
@@ -35,7 +37,7 @@ public sealed class IdentityProviderManagementService
|
||||
["standard"] = []
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<IdentityProviderManagementService> _logger;
|
||||
|
||||
// In-memory store keyed by (tenantId, id)
|
||||
@@ -44,10 +46,10 @@ public sealed class IdentityProviderManagementService
|
||||
|
||||
public IdentityProviderManagementService(
|
||||
ILogger<IdentityProviderManagementService> logger,
|
||||
IHttpClientFactory? httpClientFactory = null)
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IdentityProviderConfigDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
@@ -391,28 +393,20 @@ public sealed class IdentityProviderManagementService
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private async Task<TestConnectionResult> TestOidcConnectionAsync(
|
||||
@@ -423,29 +417,21 @@ public sealed class IdentityProviderManagementService
|
||||
var discoveryUrl = authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
using var httpClient = _httpClientFactory.CreateClient(HttpClientName);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Basic validation: should contain issuer field
|
||||
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Basic validation: should contain issuer field
|
||||
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private static IdentityProviderConfigDto MapToDto(IdentityProviderConfigEntry entry)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IdentityProviderManagementServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("saml", "idpMetadataUrl", "https://idp.example.com/metadata", "<EntityDescriptor xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"></EntityDescriptor>", "https://idp.example.com/metadata")]
|
||||
[InlineData("oidc", "authority", "https://idp.example.com", "{\"issuer\":\"https://idp.example.com\"}", "https://idp.example.com/.well-known/openid-configuration")]
|
||||
public async Task TestConnectionAsync_UsesNamedHttpClientForMetadataFetch(
|
||||
string providerType,
|
||||
string configKey,
|
||||
string configValue,
|
||||
string responseBody,
|
||||
string expectedRequestUri)
|
||||
{
|
||||
var factory = Substitute.For<IHttpClientFactory>();
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
});
|
||||
factory.CreateClient(IdentityProviderManagementService.HttpClientName)
|
||||
.Returns(new HttpClient(handler));
|
||||
|
||||
var service = new IdentityProviderManagementService(
|
||||
NullLogger<IdentityProviderManagementService>.Instance,
|
||||
factory);
|
||||
|
||||
var result = await service.TestConnectionAsync(
|
||||
new TestConnectionRequest(
|
||||
providerType,
|
||||
new Dictionary<string, string?> { [configKey] = configValue }),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(expectedRequestUri, handler.LastRequestUri);
|
||||
factory.Received(1).CreateClient(IdentityProviderManagementService.HttpClientName);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public string? LastRequestUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri?.ToString();
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
using System.Diagnostics;
|
||||
@@ -361,13 +361,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
_registryHost = $"{registryName}.azurecr.io";
|
||||
_registryUrl = $"https://{_registryHost}";
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_registryUrl + "/")
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_registryUrl + "/"));
|
||||
}
|
||||
|
||||
// Check if we need to refresh token
|
||||
@@ -450,8 +444,6 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
string clientSecret,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var tempClient = new HttpClient();
|
||||
|
||||
// Get Azure AD token
|
||||
var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
|
||||
var tokenRequest = new Dictionary<string, string>
|
||||
@@ -463,7 +455,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
};
|
||||
|
||||
using var tokenContent = new FormUrlEncodedContent(tokenRequest);
|
||||
using var tokenResponse = await tempClient.PostAsync(tokenEndpoint, tokenContent, ct);
|
||||
using var tokenResponse = await ConnectorHttpClients.SharedClient.PostAsync(tokenEndpoint, tokenContent, ct);
|
||||
tokenResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var aadToken = await tokenResponse.Content.ReadFromJsonAsync<AzureAdTokenResponse>(ct);
|
||||
@@ -478,7 +470,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
};
|
||||
|
||||
using var exchangeContent = new FormUrlEncodedContent(exchangeRequest);
|
||||
using var exchangeResponse = await tempClient.PostAsync(exchangeEndpoint, exchangeContent, ct);
|
||||
using var exchangeResponse = await ConnectorHttpClients.SharedClient.PostAsync(exchangeEndpoint, exchangeContent, ct);
|
||||
exchangeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var refreshToken = await exchangeResponse.Content.ReadFromJsonAsync<AcrRefreshTokenResponse>(ct);
|
||||
@@ -494,7 +486,7 @@ public sealed class AcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
};
|
||||
|
||||
using var accessContent = new FormUrlEncodedContent(accessRequest);
|
||||
using var accessResponse = await tempClient.PostAsync(accessEndpoint, accessContent, ct);
|
||||
using var accessResponse = await ConnectorHttpClients.SharedClient.PostAsync(accessEndpoint, accessContent, ct);
|
||||
accessResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var accessToken = await accessResponse.Content.ReadFromJsonAsync<AcrAccessTokenResponse>(ct);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
using System.Diagnostics;
|
||||
@@ -343,9 +343,7 @@ public sealed class DockerHubConnector : IRegistryConnectorCapability, IDisposab
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
_httpClient = ConnectorHttpClients.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<string> GetAuthTokenAsync(string repository, string scope, CancellationToken ct)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
using System.Diagnostics;
|
||||
@@ -365,13 +365,7 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
_serviceAccountKey = JsonSerializer.Deserialize<GcpServiceAccountKey>(keyJson);
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_registryUrl + "/")
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_registryUrl + "/"));
|
||||
}
|
||||
|
||||
private async Task EnsureValidTokenAsync(CancellationToken ct)
|
||||
@@ -394,7 +388,6 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
var jwt = CreateServiceAccountJwt(now);
|
||||
|
||||
// Exchange JWT for access token
|
||||
using var tempClient = new HttpClient();
|
||||
var tokenRequest = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
@@ -402,7 +395,7 @@ public sealed class GcrConnector : IRegistryConnectorCapability, IDisposable
|
||||
};
|
||||
|
||||
using var content = new FormUrlEncodedContent(tokenRequest);
|
||||
using var response = await tempClient.PostAsync(
|
||||
using var response = await ConnectorHttpClients.SharedClient.PostAsync(
|
||||
"https://oauth2.googleapis.com/token", content, ct);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
using System.Diagnostics;
|
||||
@@ -240,9 +240,7 @@ public sealed class AwsSecretsManagerConnector : IVaultConnectorCapability, IDis
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
_httpClient = ConnectorHttpClients.CreateClient();
|
||||
}
|
||||
|
||||
private async Task<JsonElement> CallSecretsManagerApiAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
|
||||
using StellaOps.ReleaseOrchestrator.Plugin.Models;
|
||||
using System.Diagnostics;
|
||||
@@ -261,13 +261,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_vaultUrl + "/")
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
_httpClient = ConnectorHttpClients.CreateClient(new Uri(_vaultUrl + "/"));
|
||||
}
|
||||
|
||||
private async Task EnsureValidTokenAsync(CancellationToken ct)
|
||||
@@ -295,8 +289,6 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
|
||||
|
||||
private async Task AuthenticateManagedIdentityAsync(CancellationToken ct)
|
||||
{
|
||||
using var tempClient = new HttpClient();
|
||||
|
||||
// Azure IMDS endpoint for managed identity
|
||||
var url = "http://169.254.169.254/metadata/identity/oauth2/token" +
|
||||
"?api-version=2018-02-01" +
|
||||
@@ -305,7 +297,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Metadata", "true");
|
||||
|
||||
using var response = await tempClient.SendAsync(request, ct);
|
||||
using var response = await ConnectorHttpClients.SharedClient.SendAsync(request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AzureTokenResponse>(ct);
|
||||
@@ -316,8 +308,6 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
|
||||
|
||||
private async Task AuthenticateServicePrincipalAsync(CancellationToken ct)
|
||||
{
|
||||
using var tempClient = new HttpClient();
|
||||
|
||||
var tokenEndpoint = $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token";
|
||||
var tokenRequest = new Dictionary<string, string>
|
||||
{
|
||||
@@ -328,7 +318,7 @@ public sealed class AzureKeyVaultConnector : IVaultConnectorCapability, IDisposa
|
||||
};
|
||||
|
||||
using var content = new FormUrlEncodedContent(tokenRequest);
|
||||
using var response = await tempClient.PostAsync(tokenEndpoint, content, ct);
|
||||
using var response = await ConnectorHttpClients.SharedClient.PostAsync(tokenEndpoint, content, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AzureTokenResponse>(ct);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Artifact.Core;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Artifact.Api;
|
||||
|
||||
@@ -22,15 +23,23 @@ namespace StellaOps.Artifact.Api;
|
||||
public sealed partial class ArtifactController : ControllerBase
|
||||
{
|
||||
private const string GetArtifactActionName = "GetArtifact";
|
||||
private const string HttpFetchClientName = "ArtifactController.FetchHttp";
|
||||
private static readonly HttpClient SharedHttpFetchClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
private readonly IArtifactStore _artifactStore;
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly ILogger<ArtifactController> _logger;
|
||||
|
||||
public ArtifactController(
|
||||
IArtifactStore artifactStore,
|
||||
ILogger<ArtifactController> logger)
|
||||
ILogger<ArtifactController> logger,
|
||||
IHttpClientFactory? httpClientFactory = null)
|
||||
{
|
||||
_artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -120,6 +120,12 @@ public static class PluginLoader
|
||||
{
|
||||
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
|
||||
where TPlugin : class
|
||||
=> LoadPlugins<TPlugin>(assemblies, serviceProvider: null);
|
||||
|
||||
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(
|
||||
IEnumerable<Assembly> assemblies,
|
||||
IServiceProvider? serviceProvider)
|
||||
where TPlugin : class
|
||||
{
|
||||
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
|
||||
|
||||
@@ -140,7 +146,7 @@ public static class PluginLoader
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.GetConstructor(Type.EmptyTypes) is null)
|
||||
if (serviceProvider is null && candidate.GetConstructor(Type.EmptyTypes) is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -148,11 +154,13 @@ public static class PluginLoader
|
||||
TPlugin? plugin;
|
||||
try
|
||||
{
|
||||
plugin = Activator.CreateInstance(candidate) as TPlugin;
|
||||
plugin = serviceProvider is null
|
||||
? Activator.CreateInstance(candidate) as TPlugin
|
||||
: ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, candidate) as TPlugin;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip plugins that cannot be created via default constructor.
|
||||
// Skip plugins that cannot be created via the available DI/default constructor path.
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class RuntimePostgresConstructionConventionTests
|
||||
{
|
||||
private static readonly string RepoRoot = FindRepoRoot();
|
||||
private static readonly HashSet<string> AllowedRuntimeRawConnectionFiles = new(StringComparer.Ordinal)
|
||||
{
|
||||
"src/Cli/__Libraries/StellaOps.Cli.Plugins.Aoc/AocVerificationService.cs",
|
||||
"src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/DatabaseSetupStep.cs",
|
||||
"src/Cli/StellaOps.Cli/Services/MigrationCommandService.cs",
|
||||
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresConnectionPoolCheck.cs",
|
||||
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresConnectivityCheck.cs",
|
||||
"src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Postgres/Checks/PostgresMigrationStatusCheck.cs",
|
||||
"src/Platform/StellaOps.Platform.WebService/Services/PlatformMigrationAdminService.cs",
|
||||
"src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs",
|
||||
"src/__Libraries/StellaOps.Doctor.Plugins.Database/Checks/DatabaseCheckBase.cs",
|
||||
"src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs",
|
||||
"src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs",
|
||||
};
|
||||
private static readonly HashSet<string> AllowedRuntimeValkeyConnectionFiles = new(StringComparer.Ordinal)
|
||||
{
|
||||
"src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/CacheSetupStep.cs",
|
||||
"src/Tools/NotifySmokeCheck/NotifySmokeCheckRunner.cs",
|
||||
"src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs",
|
||||
};
|
||||
private static readonly string[] KnownHttpLifecycleHotspots =
|
||||
[
|
||||
"src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.cs",
|
||||
"src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/TrustRepoServiceCollectionExtensions.Offline.cs",
|
||||
"src/Attestor/__Libraries/StellaOps.Attestor.TrustVerdict/Oci/TrustVerdictOciAttacher.cs",
|
||||
"src/Integrations/StellaOps.Integrations.WebService/FeedMirrorConnectorPlugins.cs",
|
||||
"src/Integrations/StellaOps.Integrations.WebService/ObjectStorageConnectorPlugins.cs",
|
||||
"src/Platform/StellaOps.Platform.WebService/Services/IdentityProviderManagementService.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/AcrConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/DockerHubConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/EcrConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GcrConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AwsSecretsManagerConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/AzureKeyVaultConnector.cs",
|
||||
"src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Vault/HashiCorpVaultConnector.cs",
|
||||
"src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs",
|
||||
"src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Runtime_code_does_not_use_anonymous_NpgsqlDataSource_Create()
|
||||
{
|
||||
var offenders = EnumerateRuntimeSourceFiles()
|
||||
.Where(file => File.ReadAllText(file).Contains("NpgsqlDataSource.Create(", StringComparison.Ordinal))
|
||||
.Select(ToRelativePath)
|
||||
.ToList();
|
||||
|
||||
offenders.Should().BeEmpty(
|
||||
"runtime PostgreSQL callers should use a named data source or the shared PostgreSQL policy path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_data_source_builders_must_apply_runtime_attribution()
|
||||
{
|
||||
var offenders = EnumerateRuntimeSourceFiles()
|
||||
.Where(file =>
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
return content.Contains("new NpgsqlDataSourceBuilder(", StringComparison.Ordinal)
|
||||
&& !content.Contains("ApplicationName", StringComparison.Ordinal)
|
||||
&& !content.Contains("PostgresConnectionStringPolicy", StringComparison.Ordinal);
|
||||
})
|
||||
.Select(ToRelativePath)
|
||||
.ToList();
|
||||
|
||||
offenders.Should().BeEmpty(
|
||||
"runtime NpgsqlDataSourceBuilder call sites must stamp ApplicationName explicitly or flow through PostgresConnectionStringPolicy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_code_does_not_use_raw_NpgsqlConnection_outside_allowlist()
|
||||
{
|
||||
var offenders = EnumerateRuntimeSourceFiles()
|
||||
.Where(file => File.ReadAllText(file).Contains("new NpgsqlConnection(", StringComparison.Ordinal))
|
||||
.Select(ToRelativePath)
|
||||
.Where(file => !AllowedRuntimeRawConnectionFiles.Contains(file))
|
||||
.ToList();
|
||||
|
||||
offenders.Should().BeEmpty(
|
||||
"runtime PostgreSQL callers should use named data sources unless they are an explicit CLI/setup/migration/diagnostic exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_valkey_connections_must_stamp_client_name()
|
||||
{
|
||||
var offenders = EnumerateRuntimeSourceFiles()
|
||||
.Where(file =>
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var relativePath = ToRelativePath(file);
|
||||
var usesValkeyMultiplexer =
|
||||
content.Contains("ConnectionMultiplexer.Connect(", StringComparison.Ordinal)
|
||||
|| content.Contains("ConnectionMultiplexer.ConnectAsync(", StringComparison.Ordinal);
|
||||
|
||||
if (!usesValkeyMultiplexer || AllowedRuntimeValkeyConnectionFiles.Contains(relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !content.Contains("ClientName", StringComparison.Ordinal);
|
||||
})
|
||||
.Select(ToRelativePath)
|
||||
.ToList();
|
||||
|
||||
offenders.Should().BeEmpty(
|
||||
"runtime Valkey callers should stamp a stable ClientName unless they are an explicit CLI/tooling exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Known_runtime_http_hotspots_do_not_allocate_ad_hoc_HttpClient()
|
||||
{
|
||||
var offenders = EnumerateRuntimeSourceFiles()
|
||||
.Where(file => KnownHttpLifecycleHotspots.Contains(ToRelativePath(file)))
|
||||
.Where(file => File.ReadAllText(file).Contains("new HttpClient(", StringComparison.Ordinal))
|
||||
.Select(ToRelativePath)
|
||||
.ToList();
|
||||
|
||||
offenders.Should().BeEmpty(
|
||||
"the scoped HTTP hardening waves removed raw runtime HttpClient allocation from the known host-owned hotspots");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateRuntimeSourceFiles()
|
||||
{
|
||||
var srcRoot = Path.Combine(RepoRoot, "src");
|
||||
return Directory.EnumerateFiles(srcRoot, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(file => !PathSegments(file).Any(segment => segment.EndsWith(".Tests", StringComparison.Ordinal)))
|
||||
.Where(file => !PathSegments(file).Any(segment => segment.EndsWith(".Benchmarks", StringComparison.Ordinal)))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}Testing{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}__Tests{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
||||
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var srcPath = Path.Combine(current.FullName, "src");
|
||||
var docsPath = Path.Combine(current.FullName, "docs");
|
||||
if (Directory.Exists(srcPath) && Directory.Exists(docsPath))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to locate the Stella Ops repository root from the test output directory.");
|
||||
}
|
||||
|
||||
private static string ToRelativePath(string fullPath)
|
||||
=> Path.GetRelativePath(RepoRoot, fullPath).Replace('\\', '/');
|
||||
|
||||
private static IEnumerable<string> PathSegments(string fullPath)
|
||||
=> fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user