Stabilize web test lane warning cleanup

This commit is contained in:
master
2026-04-06 00:51:15 +03:00
parent fc798a1573
commit f8e4bf65fb
35 changed files with 258 additions and 138 deletions

View File

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

View File

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

View File

@@ -1111,8 +1111,7 @@ internal static class AdviseChatCommandGroup
}
// Create manually with HttpClient
var httpClientFactory = services.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory?.CreateClient("ChatClient") ?? new HttpClient();
var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ChatClient");
return new ChatClient(httpClient, options);
}

View File

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

View File

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

View File

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

View File

@@ -115,8 +115,7 @@ public static class DbCommandGroup
}
// Make API request
var httpClientFactory = services.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory?.CreateClient("Api") ?? new HttpClient();
var httpClient = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Api");
DbStatusResponse? response = null;
try

View File

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

View File

@@ -810,8 +810,7 @@ public static class ExceptionCommandGroup
private static HttpClient CreateHttpClient(IServiceProvider services, StellaOpsCliOptions options)
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
var client = httpClientFactory?.CreateClient("PolicyGateway") ?? new HttpClient();
var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway");
if (client.BaseAddress is null)
{

View File

@@ -234,8 +234,7 @@ public static class ExplainCommandGroup
var options = services.GetService<StellaOpsCliOptions>();
// Get HTTP client
var httpClientFactory = services.GetService<IHttpClientFactory>();
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")

View File

@@ -271,9 +271,7 @@ public static class GateCommandGroup
};
// Call API
var httpClientFactory = services.GetService<IHttpClientFactory>();
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)

View File

@@ -398,8 +398,7 @@ public static class GuardCommandGroup
IAnsiConsole console,
CancellationToken ct)
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
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")

View File

@@ -574,8 +574,7 @@ public static class LayerSbomCommandGroup
private static HttpClient CreateHttpClient(IServiceProvider services, StellaOpsCliOptions options)
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
var client = httpClientFactory?.CreateClient("ScannerService") ?? new HttpClient();
var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "ScannerService");
if (client.BaseAddress is null)
{

View File

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

View File

@@ -657,8 +657,7 @@ public static class ReplayCommandGroup
}
var options = services.GetService<StellaOpsCliOptions>();
var clientFactory = services.GetService<IHttpClientFactory>();
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";

View File

@@ -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<StellaOps.Cli.Services.OciAttestationRegistryClient>.Instance);
}

View File

@@ -1534,9 +1534,7 @@ public static class ScoreCommandGroup
StellaOpsCliOptions options,
int timeout)
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
var client = httpClientFactory?.CreateClient("Platform")
?? new HttpClient();
var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "Platform");
if (client.BaseAddress is null)
{

View File

@@ -1206,9 +1206,7 @@ public static class ScoreGateCommandGroup
StellaOpsCliOptions options,
int timeout)
{
var httpClientFactory = services.GetService<IHttpClientFactory>();
var client = httpClientFactory?.CreateClient("PolicyGateway")
?? new HttpClient();
var client = StellaOps.Cli.Services.CliHttpClients.CreateClient(services, "PolicyGateway");
if (client.BaseAddress is null)
{

View File

@@ -141,10 +141,7 @@ public static class ScoreReplayCommandGroup
}
// Make API request
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<T>(string payload)
{
if (string.IsNullOrWhiteSpace(payload))

View File

@@ -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}",

View File

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

View File

@@ -291,7 +291,8 @@ public sealed class ScmSetupStep : SetupStepBase
Dictionary<string, string> 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('/') : "";

View File

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

View File

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

View File

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

View File

@@ -229,8 +229,7 @@ public static class TimelineCommandGroup
}
var options = services.GetService<StellaOpsCliOptions>();
var factory = services.GetService<IHttpClientFactory>();
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";

View File

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

View File

@@ -169,9 +169,7 @@ public static class VexGateScanCommandGroup
}
// Call API
var httpClientFactory = services.GetService<IHttpClientFactory>();
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<IHttpClientFactory>();
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)

View File

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

View File

@@ -186,6 +186,9 @@ internal static class Program
})
.AddEgressPolicyGuard("stellaops-cli", "sources-ingest");
services.AddHttpClient("stellaops-cli.compat")
.AddEgressPolicyGuard("stellaops-cli", "compat");
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
services.AddSingleton<MigrationCommandService>();

View File

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

View File

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

View File

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

View File

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

View File

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