From fc798a15730f6a0f7c387625984df4d029a198bc Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 6 Apr 2026 00:24:16 +0300 Subject: [PATCH] Harden remaining runtime transport lifecycles --- ...sport_pooling_and_attribution_hardening.md | 64 ++++++++++++++- docs/modules/airgap/guides/airgap-mode.md | 1 + .../runtime-transport-client-rules.md | 4 +- .../EgressPolicyTests.HttpClientFactory.cs | 25 ++++++ .../EgressHttpClientFactory.cs | 22 ++++-- .../StellaOps.AirGap.Policy/TASKS.md | 2 +- .../Registry/GenericOciConnector.cs | 8 +- .../Connectors/Registry/HarborConnector.cs | 8 +- .../Registry/JfrogArtifactoryConnector.cs | 8 +- .../Connectors/Registry/QuayConnector.cs | 8 +- .../Connectors/Scm/AzureDevOpsConnector.cs | 7 +- .../Connectors/Scm/GitHubConnector.cs | 7 +- .../Connectors/Scm/GitLabConnector.cs | 7 +- .../Connectors/Scm/GiteaConnector.cs | 7 +- .../SettingsStore/AwsAppConfigConnector.cs | 12 +-- .../AwsParameterStoreConnector.cs | 10 +-- .../SettingsStore/AzureAppConfigConnector.cs | 9 +-- .../SettingsStore/ConsulKvConnector.cs | 10 +-- .../Connectors/SettingsStore/EtcdConnector.cs | 10 +-- .../InternalsVisibleTo.cs | 3 + .../TASKS.md | 1 + .../ConnectorHttpClientsTests.cs | 32 ++++++++ .../TASKS.md | 1 + src/Workflow/AGENTS.md | 23 ++++++ .../InternalsVisibleTo.cs | 3 + .../PostgresWorkflowBackendOptions.cs | 5 ++ .../PostgresWorkflowDatabase.cs | 29 ++++++- .../PostgresWorkflowDatabaseTests.cs | 78 +++++++++++++++++++ ...timePostgresConstructionConventionTests.cs | 14 +++- 29 files changed, 311 insertions(+), 107 deletions(-) create mode 100644 src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/InternalsVisibleTo.cs create mode 100644 src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/ConnectorHttpClientsTests.cs create mode 100644 src/Workflow/AGENTS.md create mode 100644 src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/InternalsVisibleTo.cs create mode 100644 src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowDatabaseTests.cs diff --git a/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md b/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md index 2c74958ef..f18500893 100644 --- a/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md +++ b/docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md @@ -11,7 +11,7 @@ ## 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. +- Cross-module edits allowed for `src/AdvisoryAI/**`, `src/AirGap/**`, `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/**`, `src/Workflow/**`, `docs/**`, and `devops/**` when they consume the shared transport conventions. ## Documentation Prerequisites - `docs/code-of-conduct/CODE_OF_CONDUCT.md` @@ -23,6 +23,10 @@ - `src/__Libraries/AGENTS.md` - `src/__Libraries/StellaOps.Infrastructure.Postgres/AGENTS.md` - `src/__Tests/AGENTS.md` +- `src/AirGap/StellaOps.AirGap.Policy/AGENTS.md` +- `src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/AGENTS.md` +- `src/ReleaseOrchestrator/AGENTS.md` +- `src/Workflow/AGENTS.md` ## Delivery Tracker @@ -104,6 +108,46 @@ Completion criteria: - [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. +### XPORT-WORKFLOW-007 - Remove the remaining Workflow PostgreSQL runtime exception +Status: DONE +Dependency: XPORT-GUARD-003 +Owners: Developer +Task description: +- Add the missing Workflow module instructions so runtime storage edits are no longer blocked by repo governance. +- Normalize the Workflow PostgreSQL backend connection string with stable application-name and pooling settings, add focused regression coverage, and remove the backend from the shared raw-connection allowlist. + +Completion criteria: +- [x] `src/Workflow/AGENTS.md` exists and documents the module rules needed for runtime storage changes. +- [x] Workflow's PostgreSQL backend applies stable runtime attribution/pooling before opening raw `NpgsqlConnection` instances. +- [x] The shared convention suite no longer allowlists the Workflow PostgreSQL backend. + +### XPORT-HTTP-008 - Harden AirGap egress HTTP fallback lifecycle +Status: DONE +Dependency: XPORT-HTTP-005 +Owners: Developer +Task description: +- Replace the raw default `new HttpClient()` fallback inside `EgressHttpClientFactory` with a shared-handler compatibility path so repeated policy-approved calls do not create independent default connection pools. +- Keep the public helper contract unchanged, document the fallback behavior, and preserve per-call client isolation for callers that apply custom headers or base addresses. + +Completion criteria: +- [x] `EgressHttpClientFactory` no longer uses the default parameterless `new HttpClient()` fallback path. +- [x] Unit coverage proves the fallback still returns isolated client instances for caller-specific configuration. +- [x] AirGap module docs and task board reflect the hardened fallback behavior. + +### XPORT-HTTP-009 - Eliminate default-handler churn across ReleaseOrchestrator IntegrationHub connectors +Status: DONE +Dependency: XPORT-HTTP-006 +Owners: Developer +Task description: +- Add the missing ReleaseOrchestrator module instructions needed for autonomous connector/runtime transport edits. +- Move the remaining IntegrationHub SCM, settings-store, and registry connectors off raw default-handler `new HttpClient()` construction and onto the shared-handler compatibility wrapper while preserving per-connector client isolation for auth headers and base addresses. +- Extend the scoped HTTP guardrail and add focused helper regression coverage so the shared compatibility path stays isolated and pooled. + +Completion criteria: +- [x] `src/ReleaseOrchestrator/AGENTS.md` exists and covers connector/runtime transport work. +- [x] The remaining raw IntegrationHub connector `HttpClient` constructions route through `ConnectorHttpClients.CreateClient(...)` instead of the default handler path. +- [x] The shared convention suite and targeted IntegrationHub tests cover the broadened ReleaseOrchestrator connector hotspot set. + ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | @@ -118,6 +162,12 @@ Completion criteria: | 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 | +| 2026-04-05 | Added `src/Workflow/AGENTS.md`, normalized the Workflow PostgreSQL backend connection string with stable application name and pooling defaults, added focused Workflow regression coverage, and removed the backend from the shared raw-connection allowlist. | Developer | +| 2026-04-05 | Validation: `dotnet build src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/StellaOps.Workflow.DataStore.PostgreSQL.csproj`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests.csproj`, and `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed. | Developer | +| 2026-04-05 | Hardened the AirGap `EgressHttpClientFactory` fallback to use a shared handler instead of raw default `new HttpClient()` allocation, added isolation coverage for the fallback path, and updated the module task board plus air-gap mode guidance. | Developer | +| 2026-04-05 | Validation: `dotnet build src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj` and `dotnet test src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/StellaOps.AirGap.Policy.Tests.csproj` passed. | Developer | +| 2026-04-06 | Added `src/ReleaseOrchestrator/AGENTS.md`, routed the remaining IntegrationHub SCM, settings-store, and registry connectors through `ConnectorHttpClients.CreateClient(...)`, and added focused helper coverage for isolated shared-handler client creation. | Developer | +| 2026-04-06 | Validation: `dotnet build src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/StellaOps.ReleaseOrchestrator.IntegrationHub.csproj`, `dotnet test src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.csproj`, and `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed. | 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. @@ -126,12 +176,18 @@ Completion criteria: - 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. +- AirGap's fallback egress wrapper now uses a shared handler while still returning isolated `HttpClient` instances per call, preserving caller-specific header/base-address configuration without paying the raw default-handler churn cost. - 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. +- ReleaseOrchestrator IntegrationHub connectors still do not use `IHttpClientFactory`; this sprint broadens the shared-handler compatibility path across SCM, settings-store, and registry connectors so they stop allocating default-handler clients while preserving per-connector client isolation. +- ReleaseOrchestrator's compatibility wrapper is still not safe to client-cache broadly because many connectors mutate `DefaultRequestHeaders` with per-connector auth state; a future refactor needs request-scoped headers or typed/factory clients before shared client instances can be introduced there. +- Workflow now has module-local instructions, and its PostgreSQL store normalizes `ApplicationName` plus pooling before opening raw `NpgsqlConnection` instances; it remains a direct-connection implementation for now, but it is no longer an anonymous runtime exception. +- The remaining explicit raw-connection allowlist is intentionally narrow: CLI/setup, migrations, diagnostics, and `PlatformMigrationAdminService`. - 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. +- Continue the 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. +- Continue the broader HTTP/SCM/Vault-style lifecycle sweep with special focus on connector stacks that still mutate `DefaultRequestHeaders` on shared compatibility clients, because those need request-scoped auth/header refactors before client caching is safe. +- Continue the connector HTTP sweep with request-scoped auth/header refactors for ReleaseOrchestrator and the remaining CLI fallbacks, because those are now the main sources of duplicated runtime client setup after the shared-handler migration. +- Evaluate whether Workflow should move from normalized raw `NpgsqlConnection` usage to a module-scoped `NpgsqlDataSource` wrapper in a future storage refactor, but it is no longer a blocker for the shared convention suite. diff --git a/docs/modules/airgap/guides/airgap-mode.md b/docs/modules/airgap/guides/airgap-mode.md index b651670a9..e5eb506f4 100644 --- a/docs/modules/airgap/guides/airgap-mode.md +++ b/docs/modules/airgap/guides/airgap-mode.md @@ -62,6 +62,7 @@ Air-Gapped Mode is the supported operating profile for deployments with **zero e - **CLI guard:** the CLI now routes outbound HTTP through the shared egress policy. When sealed, commands that would dial external endpoints (for example, `scanner download` or remote `sources ingest` URIs) are refused with `AIRGAP_EGRESS_BLOCKED` messaging and remediation guidance instead of attempting the network call. - **Observability exporters:** `StellaOps.Telemetry.Core` now binds OTLP exporters to the configured egress policy. When sealed, any collector endpoint that is not loopback or allow-listed is skipped at startup and a structured warning is written so operators see the remediation guidance without leaving sealed mode. - **Linting/CI:** enable the `StellaOps.AirGap.Policy.Analyzers` package in solution-level analyzers so CI fails on raw `HttpClient` usage. The analyzer emits `AIRGAP001` and the bundled code fix rewrites to `EgressHttpClientFactory.Create(...)`; treat analyzer warnings as errors in sealed-mode pipelines. +- **Egress wrapper fallback:** when DI-managed `IHttpClientFactory` wiring is unavailable, `EgressHttpClientFactory.Create(...)` now falls back to a shared-handler HTTP client path instead of creating a brand-new default handler/connection pool for each request. Service-owned hosts should still prefer the overload that accepts a caller-supplied factory client so naming, retries, and other host policy can flow through. ## Testing & verification diff --git a/docs/technical/runtime-transport-client-rules.md b/docs/technical/runtime-transport-client-rules.md index c3e955e40..08c81cf11 100644 --- a/docs/technical/runtime-transport-client-rules.md +++ b/docs/technical/runtime-transport-client-rules.md @@ -6,6 +6,7 @@ This document defines the minimum lifecycle and attribution rules for long-lived - 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. +- Any temporary runtime exception that still uses raw `NpgsqlConnection` must normalize `ApplicationName` and pooling policy before opening the connection. ## Valkey / Redis - Steady-state runtime `ConnectionMultiplexer` construction must stamp a stable `ClientName`. @@ -16,8 +17,9 @@ This document defines the minimum lifecycle and attribution rules for long-lived ## 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. +- Compatibility wrappers may still return per-call `HttpClient` instances when callers need isolated headers or base addresses, but those wrappers should share the underlying handler/pool rather than constructing default-handler clients repeatedly. - 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. +- 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 plus the broadened SCM/settings-store/registry connector set, 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. diff --git a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/EgressPolicyTests.HttpClientFactory.cs b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/EgressPolicyTests.HttpClientFactory.cs index 24bcd0b6a..ac14a39ed 100644 --- a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/EgressPolicyTests.HttpClientFactory.cs +++ b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/EgressPolicyTests.HttpClientFactory.cs @@ -19,4 +19,29 @@ public sealed partial class EgressPolicyTests Assert.True(recordingPolicy.EnsureAllowedCalled); Assert.NotNull(client); } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void EgressHttpClientFactory_Create_UsingFallbackFactory_ReturnsIsolatedClients() + { + var recordingPolicy = new RecordingPolicy(); + var request = new EgressRequest("Component", new Uri("https://allowed.internal"), "mirror-sync"); + + using var first = EgressHttpClientFactory.Create( + recordingPolicy, + request, + client => + { + client.BaseAddress = new Uri("https://first.internal/"); + client.DefaultRequestHeaders.Add("X-Test", "one"); + }); + using var second = EgressHttpClientFactory.Create(recordingPolicy, request); + + Assert.True(recordingPolicy.EnsureAllowedCalled); + Assert.Equal(new Uri("https://first.internal/"), first.BaseAddress); + Assert.Null(second.BaseAddress); + Assert.True(first.DefaultRequestHeaders.Contains("X-Test")); + Assert.False(second.DefaultRequestHeaders.Contains("X-Test")); + Assert.NotEmpty(second.DefaultRequestHeaders.UserAgent); + } } diff --git a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/EgressHttpClientFactory.cs b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/EgressHttpClientFactory.cs index 45b077d4e..1ce4f75fa 100644 --- a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/EgressHttpClientFactory.cs +++ b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/EgressHttpClientFactory.cs @@ -1,5 +1,7 @@ using System; +using System.Net; using System.Net.Http; +using System.Net.Http.Headers; namespace StellaOps.AirGap.Policy; @@ -8,6 +10,13 @@ namespace StellaOps.AirGap.Policy; /// public static class EgressHttpClientFactory { + private static readonly SocketsHttpHandler SharedHandler = new() + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + /// /// Creates an after validating the supplied egress request against the policy. /// @@ -19,11 +28,7 @@ public static class EgressHttpClientFactory { ArgumentNullException.ThrowIfNull(egressPolicy); - egressPolicy.EnsureAllowed(request); - - var client = new HttpClient(); - configure?.Invoke(client); - return client; + return Create(egressPolicy, request, CreateFallbackClient, configure); } /// @@ -90,4 +95,11 @@ public static class EgressHttpClientFactory Func clientFactory, Action? configure = null) => Create(egressPolicy, new EgressRequest(component, destination, intent), clientFactory, configure); + + private static HttpClient CreateFallbackClient() + { + var client = new HttpClient(SharedHandler, disposeHandler: false); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOpsAirGapPolicy", "1.0")); + return client; + } } diff --git a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/TASKS.md b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/TASKS.md index a3d623d82..093f872d5 100644 --- a/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/TASKS.md +++ b/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/TASKS.md @@ -7,6 +7,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | --- | --- | --- | | AUDIT-0030-M | DONE | Revalidated 2026-01-06; new findings recorded in audit report. | | AUDIT-0030-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0033. | -| AUDIT-0030-A | TODO | Replace direct new HttpClient usage in EgressHttpClientFactory. | +| AUDIT-0030-A | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: `EgressHttpClientFactory` now uses a shared-handler fallback instead of raw default `new HttpClient()` allocation. | | REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.md. | | REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. | diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GenericOciConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GenericOciConnector.cs index 76ce6394e..49ac42f24 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GenericOciConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/GenericOciConnector.cs @@ -362,10 +362,7 @@ public sealed class GenericOciConnector : IRegistryConnectorCapability, IDisposa } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_registryUrl + "/") - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_registryUrl + "/")); if (!string.IsNullOrEmpty(_username)) { @@ -375,9 +372,6 @@ public sealed class GenericOciConnector : IRegistryConnectorCapability, IDisposa new AuthenticationHeaderValue("Basic", credentials); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/HarborConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/HarborConnector.cs index 79f9cdc83..53bc95229 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/HarborConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/HarborConnector.cs @@ -375,10 +375,7 @@ public sealed class HarborConnector : IRegistryConnectorCapability, IDisposable } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_harborUrl + "/") - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_harborUrl + "/")); if (!string.IsNullOrEmpty(_username)) { @@ -388,9 +385,6 @@ public sealed class HarborConnector : IRegistryConnectorCapability, IDisposable new AuthenticationHeaderValue("Basic", credentials); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/JfrogArtifactoryConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/JfrogArtifactoryConnector.cs index d10d4c71c..1c48045b9 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/JfrogArtifactoryConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/JfrogArtifactoryConnector.cs @@ -523,10 +523,7 @@ public sealed class JfrogArtifactoryConnector : IRegistryConnectorCapability, ID } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_artifactoryUrl + "/") - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_artifactoryUrl + "/")); // Set authorization header based on available auth if (!string.IsNullOrEmpty(_accessToken)) @@ -546,9 +543,6 @@ public sealed class JfrogArtifactoryConnector : IRegistryConnectorCapability, ID new AuthenticationHeaderValue("Basic", credentials); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/QuayConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/QuayConnector.cs index 509aa954c..cba72b0f0 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/QuayConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/QuayConnector.cs @@ -417,10 +417,7 @@ public sealed class QuayConnector : IRegistryConnectorCapability, IDisposable } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_quayUrl + "/") - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_quayUrl + "/")); // Set authorization header if (!string.IsNullOrEmpty(_oauth2Token)) @@ -436,9 +433,6 @@ public sealed class QuayConnector : IRegistryConnectorCapability, IDisposable new AuthenticationHeaderValue("Basic", credentials); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/AzureDevOpsConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/AzureDevOpsConnector.cs index e17214cc5..de16b966d 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/AzureDevOpsConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/AzureDevOpsConnector.cs @@ -350,16 +350,11 @@ public sealed class AzureDevOpsConnector : IScmConnectorCapability, IDisposable var authValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($":{pat}")); - _httpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(baseUrl)); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitHubConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitHubConnector.cs index 08305b9a4..000945300 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitHubConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitHubConnector.cs @@ -285,16 +285,11 @@ public sealed class GitHubConnector : IScmConnectorCapability, IDisposable } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(baseUrl)); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); return _httpClient; diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitLabConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitLabConnector.cs index 54bdb936b..6e0ee7339 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitLabConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitLabConnector.cs @@ -283,15 +283,10 @@ public sealed class GitLabConnector : IScmConnectorCapability, IDisposable } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(baseUrl)); _httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", token); _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GiteaConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GiteaConnector.cs index 89c71ac61..b73f084d8 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GiteaConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GiteaConnector.cs @@ -292,16 +292,11 @@ public sealed class GiteaConnector : IScmConnectorCapability, IDisposable _baseUrl = baseUrlProp.GetString()!.TrimEnd('/'); var apiUrl = _baseUrl + "/api/v1/"; - _httpClient = new HttpClient - { - BaseAddress = new Uri(apiUrl) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(apiUrl)); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsAppConfigConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsAppConfigConnector.cs index 91088a474..d0db88909 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsAppConfigConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsAppConfigConnector.cs @@ -454,7 +454,7 @@ public sealed class AwsAppConfigConnector : ISettingsStoreConnectorCapability, I // Use the AppConfig Data API for configuration retrieval var dataEndpoint = $"https://appconfigdata.{_region}.amazonaws.com"; - using var dataClient = new HttpClient { BaseAddress = new Uri(dataEndpoint) }; + using var dataClient = ConnectorHttpClients.CreateClient(new Uri(dataEndpoint)); // Start a configuration session var sessionRequest = CreateAppConfigDataRequest( @@ -561,14 +561,8 @@ public sealed class AwsAppConfigConnector : ISettingsStoreConnectorCapability, I var endpoint = $"https://appconfig.{_region}.amazonaws.com"; - _httpClient = new HttpClient - { - BaseAddress = new Uri(endpoint + "/"), - Timeout = TimeSpan.FromSeconds(30) - }; - - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); + _httpClient = ConnectorHttpClients.CreateClient(new Uri(endpoint + "/")); + _httpClient.Timeout = TimeSpan.FromSeconds(30); } private HttpRequestMessage CreateAppConfigRequest(HttpMethod method, string path, string? payload) diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsParameterStoreConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsParameterStoreConnector.cs index 613867ff8..7c007e5e2 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsParameterStoreConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsParameterStoreConnector.cs @@ -480,14 +480,8 @@ public sealed class AwsParameterStoreConnector : ISettingsStoreConnectorCapabili var endpoint = $"https://ssm.{_region}.amazonaws.com"; - _httpClient = new HttpClient - { - BaseAddress = new Uri(endpoint + "/"), - Timeout = TimeSpan.FromSeconds(30) - }; - - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); + _httpClient = ConnectorHttpClients.CreateClient(new Uri(endpoint + "/")); + _httpClient.Timeout = TimeSpan.FromSeconds(30); } private HttpRequestMessage CreateSsmRequest(string action, string payload) diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AzureAppConfigConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AzureAppConfigConnector.cs index 579d4b0b8..c646d4c40 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AzureAppConfigConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AzureAppConfigConnector.cs @@ -528,16 +528,11 @@ public sealed class AzureAppConfigConnector : ISettingsStoreConnectorCapability, _label = labelProp.GetString(); } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_endpoint + "/"), - Timeout = TimeSpan.FromSeconds(30) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_endpoint + "/")); + _httpClient.Timeout = TimeSpan.FromSeconds(30); _httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.microsoft.appconfig.kv+json")); - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/ConsulKvConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/ConsulKvConnector.cs index c7965e2d8..375ff052a 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/ConsulKvConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/ConsulKvConnector.cs @@ -462,20 +462,14 @@ public sealed class ConsulKvConnector : ISettingsStoreConnectorCapability, IDisp } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_consulAddress + "/"), - Timeout = TimeSpan.FromMinutes(6) // Allow for blocking queries - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_consulAddress + "/")); + _httpClient.Timeout = TimeSpan.FromMinutes(6); // Allow for blocking queries if (!string.IsNullOrEmpty(_token)) { _httpClient.DefaultRequestHeaders.Add("X-Consul-Token", _token); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/EtcdConnector.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/EtcdConnector.cs index b62cc6eaf..f62b2456f 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/EtcdConnector.cs +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/EtcdConnector.cs @@ -473,11 +473,8 @@ public sealed class EtcdConnector : ISettingsStoreConnectorCapability, IDisposab } } - _httpClient = new HttpClient - { - BaseAddress = new Uri(_etcdAddress + "/"), - Timeout = TimeSpan.FromMinutes(6) - }; + _httpClient = ConnectorHttpClients.CreateClient(new Uri(_etcdAddress + "/")); + _httpClient.Timeout = TimeSpan.FromMinutes(6); // Authenticate if credentials provided if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password)) @@ -485,9 +482,6 @@ public sealed class EtcdConnector : ISettingsStoreConnectorCapability, IDisposab await AuthenticateAsync(ct); } - _httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue("StellaOps", "1.0")); - return _httpClient; } diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/InternalsVisibleTo.cs b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/InternalsVisibleTo.cs new file mode 100644 index 000000000..f7d5212ad --- /dev/null +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.ReleaseOrchestrator.IntegrationHub.Tests")] diff --git a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md index 9b278e2ff..f7bc16b0f 100644 --- a/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md +++ b/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/TASKS.md @@ -5,5 +5,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. | +| SPRINT_20260406_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: SCM, settings-store, and remaining registry connectors now source their per-connector `HttpClient` instances from `ConnectorHttpClients.CreateClient(...)` so they reuse the shared pooled handler without leaking auth headers across integrations. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/StellaOps.ReleaseOrchestrator.IntegrationHub.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/ConnectorHttpClientsTests.cs b/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/ConnectorHttpClientsTests.cs new file mode 100644 index 000000000..12c26e8d1 --- /dev/null +++ b/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/ConnectorHttpClientsTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; + +using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors; + +namespace StellaOps.ReleaseOrchestrator.IntegrationHub.Tests; + +public sealed class ConnectorHttpClientsTests +{ + [Fact] + public void CreateClient_AssignsBaseAddressAndSharedUserAgent() + { + using var client = ConnectorHttpClients.CreateClient(new Uri("https://connector.example/api/")); + + Assert.Equal(new Uri("https://connector.example/api/"), client.BaseAddress); + Assert.Contains(client.DefaultRequestHeaders.UserAgent, agent => agent.Product?.Name == "StellaOps"); + } + + [Fact] + public void CreateClient_ReturnsIsolatedClientInstances() + { + using var first = ConnectorHttpClients.CreateClient(); + using var second = ConnectorHttpClients.CreateClient(); + + first.DefaultRequestHeaders.Add("X-Test-Header", "one"); + + Assert.NotSame(first, second); + Assert.True(first.DefaultRequestHeaders.Contains("X-Test-Header")); + Assert.False(second.DefaultRequestHeaders.Contains("X-Test-Header")); + Assert.True(second.DefaultRequestHeaders.UserAgent.Any()); + } +} diff --git a/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/TASKS.md b/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/TASKS.md index cb6bf102a..c36768c54 100644 --- a/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/TASKS.md +++ b/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/TASKS.md @@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol | Task ID | Status | Notes | | --- | --- | --- | +| SPRINT_20260406_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: added `ConnectorHttpClients` regression coverage for isolated per-connector clients on the shared pooled handler path. | | REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests/StellaOps.ReleaseOrchestrator.IntegrationHub.Tests.md. | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | diff --git a/src/Workflow/AGENTS.md b/src/Workflow/AGENTS.md new file mode 100644 index 000000000..2411aad8d --- /dev/null +++ b/src/Workflow/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS - Workflow Module + +## Working Directory +- `src/Workflow/**` (workflow abstractions, engine, storage backends, signaling, and tests). + +## Required Reading +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/release-orchestrator/modules/workflow-engine.md` + +## Engineering Rules +- Preserve deterministic workflow state transitions, event ordering, and projection behavior across storage backends. +- Keep PostgreSQL, signaling, and engine changes compatible at the contract level unless the sprint explicitly scopes a cross-backend divergence. +- Runtime storage code must follow the shared transport attribution rules: stable client identity, pooled connections, and no anonymous long-lived transports. +- Avoid cross-module edits unless the active sprint explicitly allows them. + +## Testing & Verification +- Workflow backend/storage changes must run the targeted backend test project, not just the broader module solution. +- Prefer focused integration coverage for persistence, signaling, wake-outbox, and projection flows when touching storage or transport behavior. + +## Sprint Discipline +- Track task state in the active sprint file and record blockers or contract changes in `Decisions & Risks`. diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/InternalsVisibleTo.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/InternalsVisibleTo.cs new file mode 100644 index 000000000..890a80759 --- /dev/null +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StellaOps.Workflow.DataStore.PostgreSQL.Tests")] diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs index 284af2297..256fb6ff3 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowBackendOptions.cs @@ -7,6 +7,11 @@ public sealed class PostgresWorkflowBackendOptions public const string SectionName = $"{WorkflowBackendOptions.SectionName}:Postgres"; public string ConnectionStringName { get; set; } = "WorkflowPostgres"; + public string? ApplicationName { get; set; } + public bool Pooling { get; set; } = true; + public int MinPoolSize { get; set; } = 1; + public int MaxPoolSize { get; set; } = 100; + public int? ConnectionIdleLifetimeSeconds { get; set; } = 300; public string SchemaName { get; set; } = "srd_wfklw"; public string RuntimeStatesTableName { get; set; } = "wf_runtime_states"; public string HostedJobLocksTableName { get; set; } = "wf_host_locks"; diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs index 8cefd0992..7186922a7 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.DataStore.PostgreSQL/PostgresWorkflowDatabase.cs @@ -18,6 +18,7 @@ public sealed class PostgresWorkflowDatabase( PostgresWorkflowMutationSessionAccessor sessionAccessor, IWorkflowMutationScopeAccessor scopeAccessor) { + private const string DefaultApplicationName = "stellaops-workflow"; private readonly PostgresWorkflowBackendOptions postgres = options.Value; internal PostgresWorkflowBackendOptions Options => postgres; @@ -86,14 +87,36 @@ public sealed class PostgresWorkflowDatabase( } internal async Task OpenConnectionAsync(CancellationToken cancellationToken = default) + { + var connection = new NpgsqlConnection(BuildConnectionString()); + await connection.OpenAsync(cancellationToken); + return connection; + } + + internal string BuildConnectionString() { var connectionString = configuration.GetConnectionString(postgres.ConnectionStringName) ?? throw new InvalidOperationException( $"PostgreSQL workflow backend requires connection string '{postgres.ConnectionStringName}'."); - var connection = new NpgsqlConnection(connectionString); - await connection.OpenAsync(cancellationToken); - return connection; + var normalizedMinPoolSize = Math.Max(postgres.MinPoolSize, 0); + var normalizedMaxPoolSize = Math.Max(postgres.MaxPoolSize, normalizedMinPoolSize); + var builder = new NpgsqlConnectionStringBuilder(connectionString) + { + ApplicationName = string.IsNullOrWhiteSpace(postgres.ApplicationName) + ? DefaultApplicationName + : postgres.ApplicationName.Trim(), + Pooling = postgres.Pooling, + MinPoolSize = normalizedMinPoolSize, + MaxPoolSize = normalizedMaxPoolSize, + }; + + if (postgres.ConnectionIdleLifetimeSeconds.HasValue && postgres.ConnectionIdleLifetimeSeconds.Value > 0) + { + builder.ConnectionIdleLifetime = postgres.ConnectionIdleLifetimeSeconds.Value; + } + + return builder.ToString(); } } diff --git a/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowDatabaseTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowDatabaseTests.cs new file mode 100644 index 000000000..03c347af0 --- /dev/null +++ b/src/Workflow/__Tests/StellaOps.Workflow.DataStore.PostgreSQL.Tests/PostgresWorkflowDatabaseTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; + +using StellaOps.Workflow.DataStore.PostgreSQL; +using StellaOps.Workflow.Engine.Services; + +using FluentAssertions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +using Npgsql; + +using NUnit.Framework; + +namespace StellaOps.Workflow.DataStore.PostgreSQL.Tests; + +[TestFixture] +public class PostgresWorkflowDatabaseTests +{ + [Test] + public void BuildConnectionString_WhenApplicationNameIsOmitted_UsesWorkflowDefaults() + { + var options = new PostgresWorkflowBackendOptions + { + ConnectionStringName = "WorkflowPostgres", + MinPoolSize = 2, + MaxPoolSize = 9, + ConnectionIdleLifetimeSeconds = 123, + }; + + var connectionString = CreateDatabase(options).BuildConnectionString(); + var builder = new NpgsqlConnectionStringBuilder(connectionString); + + builder.ApplicationName.Should().Be("stellaops-workflow"); + builder.Pooling.Should().BeTrue(); + builder.MinPoolSize.Should().Be(2); + builder.MaxPoolSize.Should().Be(9); + builder.ConnectionIdleLifetime.Should().Be(123); + } + + [Test] + public void BuildConnectionString_WhenExplicitSettingsAreProvided_AppliesNormalizedValues() + { + var options = new PostgresWorkflowBackendOptions + { + ConnectionStringName = "WorkflowPostgres", + ApplicationName = "workflow-projection-tests", + Pooling = false, + MinPoolSize = 8, + MaxPoolSize = 3, + }; + + var connectionString = CreateDatabase(options).BuildConnectionString(); + var builder = new NpgsqlConnectionStringBuilder(connectionString); + + builder.ApplicationName.Should().Be("workflow-projection-tests"); + builder.Pooling.Should().BeFalse(); + builder.MinPoolSize.Should().Be(8); + builder.MaxPoolSize.Should().Be(8); + } + + private static PostgresWorkflowDatabase CreateDatabase(PostgresWorkflowBackendOptions options) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"ConnectionStrings:{options.ConnectionStringName}"] = + "Host=localhost;Database=workflow;Username=workflow;Password=workflow", + }) + .Build(); + + return new PostgresWorkflowDatabase( + configuration, + Options.Create(options), + new PostgresWorkflowMutationSessionAccessor(), + new WorkflowMutationScopeAccessor()); + } +} diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs index 465ade1b1..854ac5002 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs @@ -17,7 +17,6 @@ public sealed class RuntimePostgresConstructionConventionTests "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", @@ -36,10 +35,23 @@ public sealed class RuntimePostgresConstructionConventionTests "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/GenericOciConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/HarborConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/JfrogArtifactoryConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Registry/QuayConnector.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/Scm/AzureDevOpsConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GiteaConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitHubConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/Scm/GitLabConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsAppConfigConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AwsParameterStoreConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/AzureAppConfigConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/ConsulKvConnector.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/SettingsStore/EtcdConnector.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",