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 f18500893..b4adfc0a5 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/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. +- Cross-module edits allowed for `src/AdvisoryAI/**`, `src/AirGap/**`, `src/Attestor/**`, `src/Authority/**`, `src/BinaryIndex/**`, `src/Cli/**`, `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` @@ -25,6 +25,8 @@ - `src/__Tests/AGENTS.md` - `src/AirGap/StellaOps.AirGap.Policy/AGENTS.md` - `src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/AGENTS.md` +- `src/Cli/AGENTS.md` +- `src/Cli/StellaOps.Cli/AGENTS.md` - `src/ReleaseOrchestrator/AGENTS.md` - `src/Workflow/AGENTS.md` @@ -148,6 +150,19 @@ Completion criteria: - [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. +### XPORT-HTTP-010 - Finish CLI fallback hardening and convert the HTTP guardrail to an allowlist +Status: DONE +Dependency: XPORT-HTTP-009 +Owners: Developer +Task description: +- Replace the remaining CLI command/setup default-handler `HttpClient` fallbacks with a shared compatibility helper so CLI runtime paths no longer allocate independent transport pools when named DI clients are unavailable. +- Tighten the shared HTTP convention test from a hotspot list into an explicit allowlist covering only the remaining documented compatibility wrappers and diagnostics/local-socket transports. + +Completion criteria: +- [x] CLI runtime command/setup fallbacks use a shared compatibility helper instead of raw default-handler `new HttpClient()` construction. +- [x] The shared convention suite fails any new runtime `HttpClient` construction outside the explicit allowlist. +- [x] CLI task boards, shared transport docs, and sprint notes reflect the narrowed set of intentional HTTP exceptions. + ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | @@ -168,6 +183,8 @@ Completion criteria: | 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 | +| 2026-04-06 | Added `CliHttpClients`, moved the remaining CLI command/setup fallback call sites onto the shared compatibility helper, and replaced the narrow HTTP hotspot regression check with a repo-wide allowlisted runtime `HttpClient` guardrail. | Developer | +| 2026-04-06 | Validation: `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj` passed; `dotnet test src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj` passed `82/82`; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -class StellaOps.Cli.Tests.Services.CliHttpClientsTests` passed `3/3`. A full `dotnet test src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj --filter CliHttpClientsTests` attempt showed that Microsoft Testing Platform ignored the VSTest filter and ran the full assembly, which still has seven unrelated existing failures. | 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. @@ -175,8 +192,9 @@ Completion criteria: - 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. +- The shared HTTP guardrail is now repo-wide for runtime code: only the documented compatibility wrappers and explicit diagnostics/local-socket transports remain allowlisted for direct `new HttpClient(...)` construction. - 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. +- xUnit v3 CLI tests currently need direct runner filters such as `StellaOps.Cli.Tests.exe -class ...` for targeted validation because Microsoft Testing Platform ignores legacy VSTest `--filter` arguments in this project. - 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 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. @@ -184,10 +202,9 @@ Completion criteria: - 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. +- HTTP compatibility fallbacks now live behind module-specific wrappers (`Integrations` shared defaults, `ReleaseOrchestrator` shared-handler connector clients, CLI shared compatibility clients, AirGap egress fallback, and OCI helper shared clients) so runtime hotspot files no longer construct raw clients directly. +- The remaining runtime `HttpClient` allowlist is explicit: AirGap compatibility fallback, CLI compatibility fallback, ReleaseOrchestrator compatibility wrapper, Doctor environment TLS probe, and Zastava Docker local-socket transport. ## Next Checkpoints -- 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. +- Optional future refinement: convert the remaining documented HTTP compatibility wrappers to true typed/factory-managed clients where host DI seams already exist. +- Optional future refinement: evaluate whether Workflow should move from normalized raw `NpgsqlConnection` usage to a module-scoped `NpgsqlDataSource` wrapper, though it is no longer a blocker for the shared convention suite. diff --git a/docs/technical/runtime-transport-client-rules.md b/docs/technical/runtime-transport-client-rules.md index 08c81cf11..8bfdd8b85 100644 --- a/docs/technical/runtime-transport-client-rules.md +++ b/docs/technical/runtime-transport-client-rules.md @@ -19,10 +19,11 @@ This document defines the minimum lifecycle and attribution rules for long-lived - 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 plus the broadened SCM/settings-store/registry connector set, and OCI fallback publishers. +- Existing analyzer-based guardrails remain in place for specialized modules, and the shared convention suite now enforces a repo-wide runtime `HttpClient` allowlist. +- The current explicit runtime HTTP exceptions are: AirGap fallback wrapper, CLI fallback wrapper, ReleaseOrchestrator connector compatibility wrapper, Doctor's per-probe TLS capture client, and Zastava's Docker local-socket transport client. ## 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. +- `src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs` enforces the shared PostgreSQL and Valkey runtime construction rules plus the repo-wide runtime HTTP allowlist. ## Operational Goal - Every long-lived runtime transport should be attributable in production diagnostics without relying on IP-only correlation. diff --git a/src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs index 18514a46d..44747d00a 100644 --- a/src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/Advise/AdviseChatCommandGroup.cs @@ -1111,8 +1111,7 @@ internal static class AdviseChatCommandGroup } // Create manually with HttpClient - var httpClientFactory = services.GetService(); - var httpClient = httpClientFactory?.CreateClient("ChatClient") ?? new HttpClient(); + var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ChatClient"); return new ChatClient(httpClient, options); } diff --git a/src/Cli/StellaOps.Cli/Commands/BundleVerifyCommand.cs b/src/Cli/StellaOps.Cli/Commands/BundleVerifyCommand.cs index 775e39797..275d89416 100644 --- a/src/Cli/StellaOps.Cli/Commands/BundleVerifyCommand.cs +++ b/src/Cli/StellaOps.Cli/Commands/BundleVerifyCommand.cs @@ -1348,7 +1348,7 @@ public static class BundleVerifyCommand try { - using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + using var http = StellaOps.Cli.Services.CliHttpClients.CreateClient(timeout: TimeSpan.FromSeconds(60)); var url = $"{blobSource.TrimEnd('/')}/v2/_blobs/{digest}"; var response = await http.GetAsync(url, ct); if (response.IsSuccessStatusCode) diff --git a/src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs index f40626bd5..afcb7bbf5 100644 --- a/src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs @@ -425,7 +425,8 @@ public static class ChainCommandGroup } // Call the chain API - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var endpoint = direction switch { ChainDirection.Upstream => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/upstream?maxDepth={maxDepth}", @@ -502,7 +503,8 @@ public static class ChainCommandGroup } // Call the chain API - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var targetId = attestationId; if (string.IsNullOrWhiteSpace(targetId) && !string.IsNullOrWhiteSpace(artifact)) { @@ -585,7 +587,8 @@ public static class ChainCommandGroup } // Call the chain graph API - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var formatParam = graphFormat.ToString().ToLowerInvariant(); var endpoint = $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/graph?format={formatParam}&maxDepth={maxDepth}"; @@ -652,7 +655,8 @@ public static class ChainCommandGroup } // Call the layer attestation API - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}"; var response = await httpClient.GetAsync(endpoint, ct); @@ -708,7 +712,8 @@ public static class ChainCommandGroup return 1; } - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/{layerIndex}"; var response = await httpClient.GetAsync(endpoint, ct); @@ -775,7 +780,8 @@ public static class ChainCommandGroup return 1; } - using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(backendUrl)); var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/create?sign={sign}"; var response = await httpClient.PostAsync(endpoint, null, ct); diff --git a/src/Cli/StellaOps.Cli/Commands/CheckpointCommands.cs b/src/Cli/StellaOps.Cli/Commands/CheckpointCommands.cs index 19882e0c8..3ad44db36 100644 --- a/src/Cli/StellaOps.Cli/Commands/CheckpointCommands.cs +++ b/src/Cli/StellaOps.Cli/Commands/CheckpointCommands.cs @@ -193,8 +193,8 @@ public static class CheckpointCommands Console.WriteLine($"Exporting checkpoint from {instance}..."); Console.WriteLine(); - using var httpClient = new HttpClient(); - httpClient.BaseAddress = new Uri(instance.TrimEnd('/') + "/"); + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(instance.TrimEnd('/') + "/")); // Fetch current checkpoint Console.Write("Fetching checkpoint..."); diff --git a/src/Cli/StellaOps.Cli/Commands/DbCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/DbCommandGroup.cs index f1784a18f..51c24a281 100644 --- a/src/Cli/StellaOps.Cli/Commands/DbCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/DbCommandGroup.cs @@ -115,8 +115,7 @@ public static class DbCommandGroup } // Make API request - var httpClientFactory = services.GetService(); - var httpClient = httpClientFactory?.CreateClient("Api") ?? new HttpClient(); + var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Api"); DbStatusResponse? response = null; try diff --git a/src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs index f5d6dc8bc..3a2cb450c 100644 --- a/src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/EvidenceCommandGroup.cs @@ -1898,7 +1898,8 @@ public static class EvidenceCommandGroup try { - using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(serverUrl)); // Get reindex impact assessment var assessmentUrl = $"/api/v1/evidence/reindex/assess?since={since?.ToString("O") ?? ""}&batchSize={batchSize}"; @@ -2057,7 +2058,8 @@ public static class EvidenceCommandGroup try { - using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(serverUrl)); // Request continuity verification var verifyUrl = $"/api/v1/evidence/continuity/verify?oldRoot={Uri.EscapeDataString(oldRoot)}&newRoot={Uri.EscapeDataString(newRoot)}"; @@ -2232,7 +2234,8 @@ public static class EvidenceCommandGroup try { - using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(serverUrl)); if (rollback) { diff --git a/src/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs index d40656c5e..d2780d70b 100644 --- a/src/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs @@ -810,8 +810,7 @@ public static class ExceptionCommandGroup private static HttpClient CreateHttpClient(IServiceProvider services, StellaOpsCliOptions options) { - var httpClientFactory = services.GetService(); - var client = httpClientFactory?.CreateClient("PolicyGateway") ?? new HttpClient(); + var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway"); if (client.BaseAddress is null) { diff --git a/src/Cli/StellaOps.Cli/Commands/ExplainCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ExplainCommandGroup.cs index 8a2e7ae13..648d18470 100644 --- a/src/Cli/StellaOps.Cli/Commands/ExplainCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ExplainCommandGroup.cs @@ -234,8 +234,7 @@ public static class ExplainCommandGroup var options = services.GetService(); // Get HTTP client - var httpClientFactory = services.GetService(); - using var httpClient = httpClientFactory?.CreateClient("PolicyGateway") ?? new HttpClient(); + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway"); var baseUrl = options?.BackendUrl?.TrimEnd('/') ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL") diff --git a/src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs index 59f375614..3c2bc9c4f 100644 --- a/src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/GateCommandGroup.cs @@ -271,9 +271,7 @@ public static class GateCommandGroup }; // Call API - var httpClientFactory = services.GetService(); - using var client = httpClientFactory?.CreateClient("PolicyGateway") - ?? new HttpClient(); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway"); // Configure base address if not set if (client.BaseAddress is null) diff --git a/src/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs index e54343fd7..1410c184c 100644 --- a/src/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/GuardCommandGroup.cs @@ -398,8 +398,7 @@ public static class GuardCommandGroup IAnsiConsole console, CancellationToken ct) { - var httpClientFactory = services.GetService(); - using var client = httpClientFactory?.CreateClient("Scanner") ?? new HttpClient(); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Scanner"); var baseUrl = serverUrl ?? Environment.GetEnvironmentVariable("STELLA_SCANNER_URL") diff --git a/src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs index 9634db0f3..8e4c95711 100644 --- a/src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/LayerSbomCommandGroup.cs @@ -574,8 +574,7 @@ public static class LayerSbomCommandGroup private static HttpClient CreateHttpClient(IServiceProvider services, StellaOpsCliOptions options) { - var httpClientFactory = services.GetService(); - var client = httpClientFactory?.CreateClient("ScannerService") ?? new HttpClient(); + var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ScannerService"); if (client.BaseAddress is null) { diff --git a/src/Cli/StellaOps.Cli/Commands/ReachGraph/ReachGraphCommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/ReachGraph/ReachGraphCommandHandlers.cs index 492728cb5..5b387ecd3 100644 --- a/src/Cli/StellaOps.Cli/Commands/ReachGraph/ReachGraphCommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/ReachGraph/ReachGraphCommandHandlers.cs @@ -28,7 +28,8 @@ public static class ReachGraphCommandHandlers string output, string apiUrl) { - using var client = new HttpClient { BaseAddress = new Uri(apiUrl) }; + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(apiUrl)); client.DefaultRequestHeaders.Add("X-Tenant-ID", "cli-user"); // Build query string @@ -93,7 +94,8 @@ public static class ReachGraphCommandHandlers bool verbose, string apiUrl) { - using var client = new HttpClient { BaseAddress = new Uri(apiUrl) }; + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(apiUrl)); client.DefaultRequestHeaders.Add("X-Tenant-ID", "cli-user"); // Parse input files @@ -214,7 +216,8 @@ public static class ReachGraphCommandHandlers string digest, string apiUrl) { - using var client = new HttpClient { BaseAddress = new Uri(apiUrl) }; + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(apiUrl)); client.DefaultRequestHeaders.Add("X-Tenant-ID", "cli-user"); try diff --git a/src/Cli/StellaOps.Cli/Commands/ReplayCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ReplayCommandGroup.cs index b692f8fa9..7847e48db 100644 --- a/src/Cli/StellaOps.Cli/Commands/ReplayCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ReplayCommandGroup.cs @@ -657,8 +657,7 @@ public static class ReplayCommandGroup } var options = services.GetService(); - var clientFactory = services.GetService(); - var httpClient = clientFactory?.CreateClient() ?? new HttpClient(); + var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services); var baseUrl = options?.BackendUrl ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL") ?? "http://localhost:10011"; diff --git a/src/Cli/StellaOps.Cli/Commands/SbomCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/SbomCommandGroup.cs index 41de918df..803635081 100644 --- a/src/Cli/StellaOps.Cli/Commands/SbomCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/SbomCommandGroup.cs @@ -4468,7 +4468,7 @@ public static class SbomCommandGroup // In production, this would use HttpOciRegistryClient with auth. // For now, use the CLI's configured registry client. return new StellaOps.Cli.Services.OciAttestationRegistryClient( - new HttpClient(), + StellaOps.Cli.Services.CliHttpClients.CreateClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } diff --git a/src/Cli/StellaOps.Cli/Commands/ScoreCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ScoreCommandGroup.cs index 734032e18..8f8539870 100644 --- a/src/Cli/StellaOps.Cli/Commands/ScoreCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ScoreCommandGroup.cs @@ -1534,9 +1534,7 @@ public static class ScoreCommandGroup StellaOpsCliOptions options, int timeout) { - var httpClientFactory = services.GetService(); - var client = httpClientFactory?.CreateClient("Platform") - ?? new HttpClient(); + var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Platform"); if (client.BaseAddress is null) { diff --git a/src/Cli/StellaOps.Cli/Commands/ScoreGateCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ScoreGateCommandGroup.cs index 5027dad2a..8e77b57d9 100644 --- a/src/Cli/StellaOps.Cli/Commands/ScoreGateCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ScoreGateCommandGroup.cs @@ -1206,9 +1206,7 @@ public static class ScoreGateCommandGroup StellaOpsCliOptions options, int timeout) { - var httpClientFactory = services.GetService(); - var client = httpClientFactory?.CreateClient("PolicyGateway") - ?? new HttpClient(); + var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway"); if (client.BaseAddress is null) { diff --git a/src/Cli/StellaOps.Cli/Commands/ScoreReplayCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/ScoreReplayCommandGroup.cs index 5099ea263..98c57aeae 100644 --- a/src/Cli/StellaOps.Cli/Commands/ScoreReplayCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/ScoreReplayCommandGroup.cs @@ -141,10 +141,7 @@ public static class ScoreReplayCommandGroup } // Make API request - var httpClientFactory = services.GetService(); - var httpClient = TryCreateClient(httpClientFactory, "Platform") - ?? TryCreateClient(httpClientFactory, "PlatformApi") - ?? new HttpClient(); + var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Platform"); HttpResponseMessage response; try @@ -351,23 +348,6 @@ public static class ScoreReplayCommandGroup return $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"; } - private static HttpClient? TryCreateClient(IHttpClientFactory? factory, string name) - { - if (factory is null) - { - return null; - } - - try - { - return factory.CreateClient(name); - } - catch - { - return null; - } - } - private static T? TryDeserialize(string payload) { if (string.IsNullOrWhiteSpace(payload)) diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/LlmSetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/LlmSetupStep.cs index dc49c4cc8..7c53d34d5 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/LlmSetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/LlmSetupStep.cs @@ -347,8 +347,8 @@ public sealed class LlmSetupStep : SetupStepBase { try { - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(10); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + timeout: TimeSpan.FromSeconds(10)); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); var response = await client.GetAsync("https://api.openai.com/v1/models", ct); @@ -369,8 +369,8 @@ public sealed class LlmSetupStep : SetupStepBase { try { - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(10); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + timeout: TimeSpan.FromSeconds(10)); client.DefaultRequestHeaders.Add("x-api-key", apiKey); client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); @@ -395,8 +395,8 @@ public sealed class LlmSetupStep : SetupStepBase { try { - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(10); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + timeout: TimeSpan.FromSeconds(10)); var response = await client.GetAsync( $"https://generativelanguage.googleapis.com/v1beta/models?key={apiKey}", diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/RegistrySetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/RegistrySetupStep.cs index 9b7cae03e..4f9e9707d 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/RegistrySetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/RegistrySetupStep.cs @@ -184,9 +184,10 @@ public sealed class RegistrySetupStep : SetupStepBase handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; } - using var client = new HttpClient(handler); - client.BaseAddress = new Uri(url.TrimEnd('/')); - client.Timeout = TimeSpan.FromSeconds(30); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + handler, + baseAddress: new Uri(url.TrimEnd('/')), + timeout: TimeSpan.FromSeconds(30)); // Add basic auth if credentials provided if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/ScmSetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/ScmSetupStep.cs index 280b3cfeb..47037fb4e 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/ScmSetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/ScmSetupStep.cs @@ -291,7 +291,8 @@ public sealed class ScmSetupStep : SetupStepBase Dictionary config, CancellationToken ct) { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + timeout: TimeSpan.FromSeconds(30)); var baseUrl = config.TryGetValue("scm.url", out var url) ? url.TrimEnd('/') : ""; diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/SettingsStoreSetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/SettingsStoreSetupStep.cs index 46492df05..a84cbc466 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/SettingsStoreSetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/SettingsStoreSetupStep.cs @@ -381,9 +381,9 @@ public sealed class SettingsStoreSetupStep : SetupStepBase string? token, CancellationToken ct) { - using var client = new HttpClient(); - client.BaseAddress = new Uri(address.TrimEnd('/')); - client.Timeout = TimeSpan.FromSeconds(10); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(address.TrimEnd('/')), + timeout: TimeSpan.FromSeconds(10)); if (!string.IsNullOrEmpty(token)) { @@ -399,9 +399,9 @@ public sealed class SettingsStoreSetupStep : SetupStepBase private static async Task TestEtcdConnectionAsync(string endpoint, CancellationToken ct) { - using var client = new HttpClient(); - client.BaseAddress = new Uri(endpoint.TrimEnd('/')); - client.Timeout = TimeSpan.FromSeconds(10); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(endpoint.TrimEnd('/')), + timeout: TimeSpan.FromSeconds(10)); var response = await client.GetAsync("/version", ct); response.EnsureSuccessStatusCode(); diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/TelemetrySetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/TelemetrySetupStep.cs index 4139fe0d2..7a713996b 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/TelemetrySetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/TelemetrySetupStep.cs @@ -177,8 +177,8 @@ public sealed class TelemetrySetupStep : SetupStepBase if (uri.Scheme == "http" || uri.Scheme == "https") { // HTTP/gRPC endpoint - try to connect - using var client = new HttpClient(); - client.Timeout = TimeSpan.FromSeconds(5); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + timeout: TimeSpan.FromSeconds(5)); // OTLP HTTP uses different paths for different signals // Try the root or a health check path diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/VaultSetupStep.cs b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/VaultSetupStep.cs index f00bac803..bb74924fc 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/VaultSetupStep.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/Steps/Implementations/VaultSetupStep.cs @@ -305,8 +305,8 @@ public sealed class VaultSetupStep : SetupStepBase string? ns, CancellationToken ct) { - using var client = new HttpClient(); - client.BaseAddress = new Uri(address.TrimEnd('/')); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient( + baseAddress: new Uri(address.TrimEnd('/'))); if (!string.IsNullOrEmpty(token)) { diff --git a/src/Cli/StellaOps.Cli/Commands/TimelineCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/TimelineCommandGroup.cs index d8ea0bd2e..1948f61b9 100644 --- a/src/Cli/StellaOps.Cli/Commands/TimelineCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/TimelineCommandGroup.cs @@ -229,8 +229,7 @@ public static class TimelineCommandGroup } var options = services.GetService(); - var factory = services.GetService(); - var client = factory?.CreateClient() ?? new HttpClient(); + var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services); var baseUrl = options?.BackendUrl ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL") ?? "http://localhost:10011"; diff --git a/src/Cli/StellaOps.Cli/Commands/Trust/TrustCommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/Trust/TrustCommandHandlers.cs index b89240d5a..6c9ee4e19 100644 --- a/src/Cli/StellaOps.Cli/Commands/Trust/TrustCommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/Trust/TrustCommandHandlers.cs @@ -83,7 +83,9 @@ internal static class TrustCommandHandlers // Fetch initial TUF metadata Console.WriteLine($"Fetching TUF metadata from {tufUrl}..."); - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + services, + timeout: TimeSpan.FromSeconds(30)); // Fetch root.json var rootResponse = await httpClient.GetAsync($"{tufUrl.TrimEnd('/')}/root.json", cancellationToken); @@ -168,7 +170,9 @@ internal static class TrustCommandHandlers Console.WriteLine($"Syncing TUF metadata from {config.TufUrl}..."); - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient( + services, + timeout: TimeSpan.FromSeconds(30)); var tufUrl = config.TufUrl.TrimEnd('/'); // Fetch timestamp first (freshness indicator) diff --git a/src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs index 0f52d62c4..d7c033320 100644 --- a/src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/VexGateScanCommandGroup.cs @@ -169,9 +169,7 @@ public static class VexGateScanCommandGroup } // Call API - var httpClientFactory = services.GetService(); - using var client = httpClientFactory?.CreateClient("ScannerService") - ?? new HttpClient(); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ScannerService"); // Configure base address if not set if (client.BaseAddress is null) @@ -286,9 +284,7 @@ public static class VexGateScanCommandGroup } // Call API - var httpClientFactory = services.GetService(); - using var client = httpClientFactory?.CreateClient("ScannerService") - ?? new HttpClient(); + using var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ScannerService"); // Configure base address if not set if (client.BaseAddress is null) diff --git a/src/Cli/StellaOps.Cli/Commands/Watchlist/WatchlistCommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/Watchlist/WatchlistCommandHandlers.cs index 6b917038b..09a1731d1 100644 --- a/src/Cli/StellaOps.Cli/Commands/Watchlist/WatchlistCommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/Watchlist/WatchlistCommandHandlers.cs @@ -572,11 +572,10 @@ internal static class WatchlistCommandHandlers private static HttpClient GetHttpClient(IServiceProvider services) { - var factory = services.GetService(typeof(IHttpClientFactory)) as IHttpClientFactory; - return factory?.CreateClient("AttestorApi") ?? new HttpClient - { - BaseAddress = new Uri("http://localhost:5200") - }; + return StellaOps.Cli.Services.CliHttpClients.CreateClient( + services, + "AttestorApi", + baseAddress: new Uri("http://localhost:5200")); } private static string BuildDisplayName(string? issuer, string? san, string? keyId) diff --git a/src/Cli/StellaOps.Cli/Program.cs b/src/Cli/StellaOps.Cli/Program.cs index 42bcb3ba7..7d005dd21 100644 --- a/src/Cli/StellaOps.Cli/Program.cs +++ b/src/Cli/StellaOps.Cli/Program.cs @@ -186,6 +186,9 @@ internal static class Program }) .AddEgressPolicyGuard("stellaops-cli", "sources-ingest"); + services.AddHttpClient("stellaops-cli.compat") + .AddEgressPolicyGuard("stellaops-cli", "compat"); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Cli/StellaOps.Cli/Services/CliHttpClients.cs b/src/Cli/StellaOps.Cli/Services/CliHttpClients.cs new file mode 100644 index 000000000..de0b1e7ac --- /dev/null +++ b/src/Cli/StellaOps.Cli/Services/CliHttpClients.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Headers; + +namespace StellaOps.Cli.Services; + +internal static class CliHttpClients +{ + private const string CompatibilityClientName = "stellaops-cli.compat"; + private static readonly SocketsHttpHandler SharedHandler = new() + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + + public static HttpClient CreateClient( + IServiceProvider services, + string? name = null, + Uri? baseAddress = null, + TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(services); + + var factory = services.GetService(); + return CreateClient(factory, name, baseAddress, timeout); + } + + public static HttpClient CreateClient( + IHttpClientFactory? factory = null, + string? name = null, + Uri? baseAddress = null, + TimeSpan? timeout = null) + { + var client = factory is null + ? new HttpClient(SharedHandler, disposeHandler: false) + : factory.CreateClient(string.IsNullOrWhiteSpace(name) ? CompatibilityClientName : name); + + Configure(client, baseAddress, timeout); + return client; + } + + public static HttpClient CreateClient( + HttpMessageHandler handler, + Uri? baseAddress = null, + TimeSpan? timeout = null, + bool disposeHandler = true) + { + ArgumentNullException.ThrowIfNull(handler); + + var client = new HttpClient(handler, disposeHandler); + Configure(client, baseAddress, timeout); + return client; + } + + private static void Configure(HttpClient client, Uri? baseAddress, TimeSpan? timeout) + { + if (baseAddress is not null) + { + client.BaseAddress = baseAddress; + } + + if (timeout.HasValue) + { + client.Timeout = timeout.Value; + } + + if (!client.DefaultRequestHeaders.UserAgent.Any()) + { + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps.Cli", "1.0")); + } + } +} diff --git a/src/Cli/StellaOps.Cli/TASKS.md b/src/Cli/StellaOps.Cli/TASKS.md index 5b65f851a..4430a307e 100644 --- a/src/Cli/StellaOps.Cli/TASKS.md +++ b/src/Cli/StellaOps.Cli/TASKS.md @@ -65,6 +65,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). | | SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). | | STS-004 | DONE | SPRINT_20260210_004 - Added `stella verify release` command that maps to promotion bundle verification flow. | +| SPRINT_20260406_011-XPORT-HTTP | DONE | Replaced remaining CLI command/setup default-handler `HttpClient` fallbacks with `CliHttpClients` and aligned the module with the repo-wide runtime transport guardrail. | | SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08). diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliHttpClientsTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliHttpClientsTests.cs new file mode 100644 index 000000000..53e80cc50 --- /dev/null +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliHttpClientsTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Cli.Services; + +namespace StellaOps.Cli.Tests.Services; + +public sealed class CliHttpClientsTests +{ + [Fact] + public void CreateClient_without_factory_returns_isolated_clients_with_defaults() + { + using var first = CliHttpClients.CreateClient( + baseAddress: new Uri("https://api.example/"), + timeout: TimeSpan.FromSeconds(5)); + using var second = CliHttpClients.CreateClient( + baseAddress: new Uri("https://api.example/")); + + Assert.NotSame(first, second); + Assert.Equal(new Uri("https://api.example/"), first.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(5), first.Timeout); + Assert.NotEmpty(first.DefaultRequestHeaders.UserAgent); + Assert.NotEmpty(second.DefaultRequestHeaders.UserAgent); + } + + [Fact] + public void CreateClient_with_factory_uses_requested_name() + { + var services = new ServiceCollection(); + var factory = new RecordingHttpClientFactory(); + services.AddSingleton(factory); + + using var serviceProvider = services.BuildServiceProvider(); + using var client = CliHttpClients.CreateClient( + serviceProvider, + "PolicyGateway", + baseAddress: new Uri("https://policy.example/"), + timeout: TimeSpan.FromSeconds(9)); + + Assert.Equal("PolicyGateway", factory.LastName); + Assert.Equal(new Uri("https://policy.example/"), client.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(9), client.Timeout); + Assert.NotEmpty(client.DefaultRequestHeaders.UserAgent); + } + + [Fact] + public void CreateClient_with_handler_applies_requested_configuration() + { + using var client = CliHttpClients.CreateClient( + new HttpClientHandler(), + baseAddress: new Uri("https://registry.example/"), + timeout: TimeSpan.FromSeconds(11)); + + Assert.Equal(new Uri("https://registry.example/"), client.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(11), client.Timeout); + Assert.NotEmpty(client.DefaultRequestHeaders.UserAgent); + } + + private sealed class RecordingHttpClientFactory : IHttpClientFactory + { + public string? LastName { get; private set; } + + public HttpClient CreateClient(string name) + { + LastName = name; + return new HttpClient(new HttpClientHandler(), disposeHandler: true); + } + } +} diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md b/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md index aad3bfd2a..a08575235 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md @@ -40,6 +40,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | SPRINT_20260208_030-TESTS | DONE | Added isolated advise parity validation in StellaOps.Cli.AdviseParity.Tests; command passed (2 tests, 2026-02-08). | SPRINT_20260208_033-TESTS | DONE | Added isolated Unknowns export deterministic validation in StellaOps.Cli.UnknownsExport.Tests; command passed (3 tests, 2026-02-08). | STS-005 | DONE | SPRINT_20260210_004 - Updated command structure coverage for `verify release` and verification consolidation list (execution blocked by pre-existing Policy.Determinization compile errors). | +| SPRINT_20260406_011-XPORT-HTTP-T | DONE | Added `CliHttpClients` compatibility-helper coverage and validated the repo-wide runtime HTTP allowlist against the CLI fallback cleanup. | | SPRINT_20260208_031-TESTS | DONE | Isolated compare overlay deterministic validation added in StellaOps.Cli.CompareOverlay.Tests; command passed (3 tests, 2026-02-08). diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs index 854ac5002..5a8d63610 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/RuntimePostgresConstructionConventionTests.cs @@ -27,37 +27,14 @@ public sealed class RuntimePostgresConstructionConventionTests "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/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", - "src/__Libraries/StellaOps.Artifact.Core/Api/ArtifactController.FetchHttp.cs", - "src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs", - ]; + private static readonly HashSet AllowedRuntimeHttpClientFiles = new(StringComparer.Ordinal) + { + "src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/EgressHttpClientFactory.cs", + "src/Cli/StellaOps.Cli/Services/CliHttpClients.cs", + "src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Environment/Checks/EnvironmentConnectivityCheck.cs", + "src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.IntegrationHub/Connectors/ConnectorHttpClients.cs", + "src/Zastava/StellaOps.Zastava.Agent/Docker/DockerSocketClient.cs", + }; [Fact] public void Runtime_code_does_not_use_anonymous_NpgsqlDataSource_Create() @@ -129,16 +106,16 @@ public sealed class RuntimePostgresConstructionConventionTests } [Fact] - public void Known_runtime_http_hotspots_do_not_allocate_ad_hoc_HttpClient() + public void Runtime_http_client_construction_is_restricted_to_explicit_wrappers_or_diagnostics() { var offenders = EnumerateRuntimeSourceFiles() - .Where(file => KnownHttpLifecycleHotspots.Contains(ToRelativePath(file))) .Where(file => File.ReadAllText(file).Contains("new HttpClient(", StringComparison.Ordinal)) .Select(ToRelativePath) + .Where(file => !AllowedRuntimeHttpClientFiles.Contains(file)) .ToList(); offenders.Should().BeEmpty( - "the scoped HTTP hardening waves removed raw runtime HttpClient allocation from the known host-owned hotspots"); + "runtime HttpClient construction should stay behind explicit wrappers or documented diagnostics/local-socket exceptions"); } private static IEnumerable EnumerateRuntimeSourceFiles() @@ -147,6 +124,7 @@ public sealed class RuntimePostgresConstructionConventionTests 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 => !PathSegments(file).Any(segment => segment.Contains("Analyzers", 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))