feat(excititor): continuation — orchestrator test coverage + doc reconciliation
Follow-up to commit 7efa424fe (EXCITITOR-CFG-01/02/03). Captures the
continuation edits that landed alongside/after the initial commit:
- VexIngestOrchestrator.cs: additional effective-settings resolver
hardening (+63 lines).
- DefaultVexProviderRunner.cs: worker-path settings merge refinement.
- VexIngestOrchestratorTests.cs (new): focused test coverage for the
effective-settings + blocked-readiness path.
- DefaultVexProviderRunnerTests.cs: corresponding worker-path coverage.
- TASKS.md entries updated in both test projects.
Docs reconciliation:
- provider-credentials.md (new): operator credential-entry dossier
mirroring the Concelier source-credentials.md pattern.
- provider-control-plane.md: cross-link updates.
- ops/connector-setup-guide.md: authoritative-inventory pointers updated
to reference the new credential dossiers; microsoft-entra API-permission
steps generalized to "your MSRC onboarding flow" (MSRC Security Updates
API availability varies by tenant).
- SPRINT_20260422_007 execution log appended.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,8 @@ Completion criteria:
|
||||
| --- | --- | --- |
|
||||
| 2026-04-22 | Sprint created from implementation-planning review of Excititor provider credential gaps. Current findings: provider store lacks runtime settings, readiness does not validate connector config, and worker/manual run paths do not yet consume persisted provider settings. | Planning |
|
||||
| 2026-04-22 | EXCITITOR-CFG-01/02/03 landed: new `vex.provider_settings` table + migration `007_vex_provider_settings.sql` wired via embedded-resource startup migrations; `IVexProviderSettingsStore` (Postgres + in-memory) added; `VexProviderConfigurationService`, `VexProviderRuntimeSettingsCache`, and field schemas for `excititor:cisco`, `excititor:suse-rancher`, `excititor:msrc`; `GET/PUT /excititor/providers/{id}/configuration` endpoints mirror SRC-CREDS-001 `values + clearKeys` shape with masked secret state; `VexProviderManagementService` blocked-readiness now surfaces `PROVIDER_CONFIG_REQUIRED` / `PROVIDER_CONFIG_INVALID` using the real `CiscoConnectorOptions` / `RancherHubConnectorOptions` / `MsrcConnectorOptions` validators; `VexIngestOrchestrator` and `DefaultVexProviderRunner` both resolve persisted settings and overlay them on host-config baseline. CLI: `stella vex providers configure <provider> --set --clear --format` in `VexProvidersCommandGroup`. Web: `VexProviderConfigurationComponent` standalone panel + API client methods on `VexProviderManagementApi`. Targeted xUnit run against `StellaOps.Excititor.WebService.Tests.VexProviderConfigurationServiceTests` passed `Total: 8, Failed: 0`; regression run of `ProviderManagementEndpointsTests` still passes `Total: 5, Failed: 0`. | Codex |
|
||||
| 2026-04-22 | Follow-up hardening: Excititor scheduled worker now short-circuits providers blocked by missing or invalid persisted configuration instead of treating them as retry failures, clearing stale backoff while preserving a truthful operator-facing reason; `VexIngestOrchestrator` returns `blocked` per-provider results for batch run/init/reconcile flows when `PROVIDER_CONFIG_*` applies; operator docs corrected and expanded with `docs/modules/excititor/operations/provider-credentials.md` plus provider-control-plane/ops guide truthfulness fixes. | Codex |
|
||||
| 2026-04-22 | Targeted behavioral verification for the hardening slice used the repo xUnit helper because this codebase runs Microsoft Testing Platform and ignores VSTest `dotnet test --filter ...` (`MTP0001`). Evidence: `powershell -ExecutionPolicy Bypass -File .\\scripts\\test-targeted-xunit.ps1 -Project src/Concelier/__Tests/StellaOps.Excititor.Worker.Tests/StellaOps.Excititor.Worker.Tests.csproj -Method StellaOps.Excititor.Worker.Tests.DefaultVexProviderRunnerTests.RunAsync_ConfigBlocked_DoesNotFetch_AndClearsBackoff` passed `Total: 1`; `powershell -ExecutionPolicy Bypass -File .\\scripts\\test-targeted-xunit.ps1 -Project src/Concelier/__Tests/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj -Method StellaOps.Excititor.WebService.Tests.VexIngestOrchestratorTests.RunAsync_ReturnsBlocked_WhenProviderConfigurationMissing -BuildProjectReferences` passed `Total: 1`. The WebService test project also required an explicit `<Compile Include="VexIngestOrchestratorTests.cs" />` entry because it uses a closed compile list. | Codex |
|
||||
| 2026-04-22 | EXCITITOR-CFG-04 deferred (marked BLOCKED). OCI OpenVEX needs image-subscription lists plus binary credential material (registry tokens, cosign keys, offline bundles) that should not ride the flat string map used by scalar providers. Staging that shape requires a secret-reference or artifact store decision out of scope for this sprint slice; see Decisions & Risks. | Codex |
|
||||
|
||||
## Decisions & Risks
|
||||
@@ -94,6 +96,8 @@ Completion criteria:
|
||||
- Compatibility risk: existing host-config paths continue to work as fallback. The worker runner merges persisted settings on top of the schedule-supplied baseline (`DefaultVexProviderRunner.ResolveEffectiveSettingsAsync`).
|
||||
- Docs updated alongside implementation:
|
||||
- `docs/modules/excititor/operations/provider-control-plane.md`
|
||||
- `docs/modules/excititor/operations/provider-credentials.md`
|
||||
- `docs/ops/connector-setup-guide.md`
|
||||
|
||||
## Next Checkpoints
|
||||
- Contract review after `EXCITITOR-CFG-01` with decided persistence model and route shape.
|
||||
|
||||
@@ -6,12 +6,7 @@ This document describes the operator-facing control plane for Excititor VEX prov
|
||||
|
||||
- Web UI: `Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers`
|
||||
- CLI:
|
||||
- `stella excititor list-providers`
|
||||
- `stella excititor show-provider --provider <id>`
|
||||
- `stella excititor enable-provider --provider <id>`
|
||||
- `stella excititor disable-provider --provider <id>`
|
||||
- `stella excititor run-provider --provider <id> [--since ... --window ... --force]`
|
||||
- `stella excititor update-provider --provider <id> ...`
|
||||
- `stella vex providers configure <provider> [--set key=value ...] [--clear key ...] [--format text|json]`
|
||||
|
||||
Backend API:
|
||||
|
||||
@@ -26,6 +21,10 @@ Backend API:
|
||||
|
||||
Persisted connector configuration (Sprint `20260422_007`) is the primary operator path for credentialed VEX providers. Host-config and environment binding remain compatibility fallbacks only. Persisted settings win when both define the same key.
|
||||
|
||||
Related credential guide:
|
||||
|
||||
- `docs/modules/excititor/operations/provider-credentials.md`
|
||||
|
||||
## Readiness states
|
||||
|
||||
Excititor providers use four runtime readiness states:
|
||||
@@ -53,10 +52,10 @@ These codes mirror the Concelier `SOURCE_CONFIG_REQUIRED` / `SOURCE_CONFIG_INVAL
|
||||
| `excititor:redhat` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:ubuntu` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:oracle` | vendor | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:cisco` | vendor | true | yes | yes | yes | Public CSAF metadata works without a persisted secret path. Optional API token support exists in connector host options, not in the new persisted UI or CLI surface. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:suse-rancher` | hub | false | yes | yes | yes | Discovery and trust metadata can be persisted. Authenticated discovery credentials remain host-config only today. Anonymous discovery can still be allowed by connector options. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:cisco` | vendor | true | yes | yes | yes | Persisted scalar config supports `metadataUri` override plus optional `apiToken`. Default public Cisco CSAF works without credentials. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:suse-rancher` | hub | false | yes | yes | yes | Persisted scalar config supports `discoveryUri`, `tokenEndpoint`, `clientId`, `clientSecret`, and `audience`. Missing or partial auth settings surface as blocked instead of runtime surprise. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:oci-openvex` | attestation | false | yes | yes | yes | Provider metadata and trust overrides can be persisted. Image subscriptions, registry credentials, and cosign credential material remain host-config only today. | Registered by default in `StellaOps.Excititor.WebService`. |
|
||||
| `excititor:msrc` | vendor | false | conditional | yes | yes | Persisted provider metadata exists, but MSRC connector credentials and offline token settings remain host-config only today. | Registered only when `Excititor:Connectors:Msrc` exists in host configuration. Otherwise the provider remains `planned`. |
|
||||
| `excititor:msrc` | vendor | false | conditional | yes | yes | Persisted scalar config supports `tenantId`, `clientId`, `clientSecret`, and optional `scope`. Offline token-path fields still remain host-config only. | Registered only when `Excititor:Connectors:Msrc` exists in host configuration. Otherwise the provider remains `planned`. |
|
||||
|
||||
## What the current provider control plane persists
|
||||
|
||||
|
||||
129
docs/modules/excititor/operations/provider-credentials.md
Normal file
129
docs/modules/excititor/operations/provider-credentials.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Excititor Provider Credential Entry
|
||||
|
||||
_Last updated: 2026-04-22_
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Excititor now supports operator-supplied provider settings through the product surfaces operators already use:
|
||||
|
||||
- Web UI provider management
|
||||
- `stella vex providers configure ...` in the CLI
|
||||
|
||||
Host configuration and environment variables remain compatibility fallbacks, but the primary operator path for supported credential-sensitive Excititor providers is persisted provider configuration owned by Stella Ops itself.
|
||||
|
||||
## 2. Operator entry paths
|
||||
|
||||
### Web UI
|
||||
|
||||
Use:
|
||||
|
||||
- **Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers**
|
||||
|
||||
Then:
|
||||
|
||||
1. Open the provider card.
|
||||
2. Open **Provider Configuration**.
|
||||
3. Enter or update the provider fields.
|
||||
4. Save the configuration.
|
||||
|
||||
Sensitive values never round-trip back to the browser. A stored secret is shown only as retained state. Leaving a password field blank keeps the retained secret. Explicitly checking the clear control removes the stored secret.
|
||||
|
||||
### CLI
|
||||
|
||||
Inspect current persisted provider configuration:
|
||||
|
||||
```bash
|
||||
stella vex providers configure excititor:cisco --server https://excititor.example.internal
|
||||
stella vex providers configure excititor:suse-rancher --server https://excititor.example.internal
|
||||
stella vex providers configure excititor:msrc --server https://excititor.example.internal
|
||||
```
|
||||
|
||||
Update a provider:
|
||||
|
||||
```bash
|
||||
stella vex providers configure excititor:cisco \
|
||||
--server https://excititor.example.internal \
|
||||
--set metadataUri=https://mirror.example.internal/cisco/provider-metadata.json \
|
||||
--set apiToken=...
|
||||
|
||||
stella vex providers configure excititor:suse-rancher \
|
||||
--server https://excititor.example.internal \
|
||||
--set discoveryUri=https://mirror.example.internal/rancher/vexhub.json \
|
||||
--set tokenEndpoint=https://auth.example.internal/oauth2/token \
|
||||
--set clientId=... \
|
||||
--set clientSecret=...
|
||||
|
||||
stella vex providers configure excititor:msrc \
|
||||
--server https://excititor.example.internal \
|
||||
--set tenantId=... \
|
||||
--set clientId=... \
|
||||
--set clientSecret=...
|
||||
```
|
||||
|
||||
Clear stored fields:
|
||||
|
||||
```bash
|
||||
stella vex providers configure excititor:msrc \
|
||||
--server https://excititor.example.internal \
|
||||
--clear clientSecret
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--set` accepts `key=value`.
|
||||
- The current CLI path places literal values on the command line. If shell-history exposure is unacceptable for a secret, prefer the Web UI path or use an operator-approved secure shell/history procedure.
|
||||
|
||||
## 3. Blocked providers
|
||||
|
||||
If an operator enables a provider that still lacks required credentials or has an invalid persisted configuration, Excititor preserves the enable intent but reports the provider as `blocked`.
|
||||
|
||||
- `enabled=true` means the operator wants the provider scheduled once it becomes usable.
|
||||
- `readiness=blocked` means the provider is intentionally on hold because required configuration is still missing or invalid.
|
||||
- Manual provider runs and batch ingest flows return a blocked result instead of pretending the provider is runnable.
|
||||
- Scheduled worker runs skip blocked providers and record the configuration reason instead of treating missing credentials as transient retry failures.
|
||||
|
||||
Current blocked codes:
|
||||
|
||||
- `PROVIDER_CONFIG_REQUIRED`
|
||||
- `PROVIDER_CONFIG_INVALID`
|
||||
|
||||
## 4. Credential acquisition matrix
|
||||
|
||||
| Provider | Where to sign in or look | What to create or capture | Can the config be skipped? | Entitlement / paywall notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `excititor:cisco` | Public Cisco CSAF metadata by default. Optional authenticated path depends on your Cisco API / mirror arrangement. | Usually nothing for the default public path. Optionally capture `metadataUri` override and `apiToken` if your Cisco path requires bearer auth. | Yes, for the default public Cisco CSAF metadata path. Configure it only when overriding the metadata URI or when your Cisco endpoint requires a token. | No StellaOps-side paywall for the public path. Any token requirement depends on your Cisco-side arrangement, mirror, or entitlement. |
|
||||
| `excititor:suse-rancher` | Your Rancher Hub / SUSE-auth deployment, plus the corresponding identity provider or token service. | `discoveryUri`, and when auth is required: `tokenEndpoint`, `clientId`, `clientSecret`, optional `audience`. | Sometimes. Anonymous discovery is allowed only if the hub is intentionally exposed that way. Otherwise the authenticated fields are required together. | No StellaOps-side paywall. Access depends on your Rancher Hub deployment and the identity provider that fronts it. |
|
||||
| `excititor:msrc` | `https://entra.microsoft.com` -> **App registrations** | `tenantId`, `clientId`, `clientSecret`; optionally `scope` override if you are not using the default MSRC API scope. | Not for the online MSRC client-credential path. | No separate documented MSRC paywall, but you need a Microsoft Entra tenant plus permission to register the app and grant the required consent. |
|
||||
| `excititor:oci-openvex` | Registry, identity provider, cosign/PKI authority, and any offline artifact staging path used by your deployment. | Not yet supported through the persisted UI/CLI scalar config path. | No. This remains blocked pending the artifact-backed OCI configuration design. | Depends on your registry, cosign, and offline bundle environment. |
|
||||
|
||||
## 5. What operators should actually look for
|
||||
|
||||
### Cisco CSAF
|
||||
|
||||
- No login is needed for the default public Cisco CSAF metadata path.
|
||||
- Only collect `metadataUri` when pointing Excititor at an approved internal mirror.
|
||||
- Only collect `apiToken` when your Cisco-side path or mirror explicitly requires a bearer token.
|
||||
|
||||
### SUSE Rancher VEX Hub
|
||||
|
||||
- Rancher hub discovery document URI
|
||||
- OAuth or OIDC token endpoint when the hub requires authentication
|
||||
- Client ID and client secret for the hub reader application
|
||||
- Optional audience value when your token service requires it
|
||||
|
||||
### Microsoft MSRC
|
||||
|
||||
- Microsoft Entra **Directory (tenant) ID**
|
||||
- Microsoft Entra **Application (client) ID**
|
||||
- A newly created **Client secret** value
|
||||
- Confirm the app consent and scope expected by your MSRC onboarding process before storing the values in Stella Ops
|
||||
|
||||
### OCI OpenVEX
|
||||
|
||||
- No persisted UI/CLI credential path exists yet for the binary material used by OCI OpenVEX.
|
||||
- Keep using host-config compatibility mode until the artifact-backed configuration design lands.
|
||||
|
||||
## 6. References
|
||||
|
||||
- Microsoft Entra app registration quickstart: <https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app>
|
||||
- Microsoft Entra application credentials: <https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials>
|
||||
@@ -41,7 +41,20 @@ Tracked in:
|
||||
|
||||
## Credential requirements
|
||||
|
||||
Only the following connectors need operator-minted credentials — and **all three are currently in the aspirational catalog only**. You cannot configure them against a running backend until the connector code is wired. Steps are retained here so they're ready when that sprint lands.
|
||||
Authoritative current-state inventories live here:
|
||||
|
||||
- `docs/modules/concelier/connectors.md`
|
||||
- `docs/modules/concelier/operations/source-credentials.md`
|
||||
- `docs/modules/excititor/operations/provider-control-plane.md`
|
||||
- `docs/modules/excititor/operations/provider-credentials.md`
|
||||
|
||||
Current UI/CLI-configurable credentialed paths:
|
||||
|
||||
- Concelier advisory sources: `ghsa`, `cisco`, `microsoft`
|
||||
- Concelier endpoint-override paths: `oracle`, `adobe`, `chromium`
|
||||
- Excititor VEX providers: `excititor:cisco`, `excititor:suse-rancher`, `excititor:msrc`
|
||||
|
||||
The sections below keep the acquisition steps for the most common credentialed providers.
|
||||
|
||||
### GitHub Security Advisories (GHSA)
|
||||
|
||||
@@ -64,16 +77,16 @@ Steps:
|
||||
|
||||
Cisco ref: <https://developer.cisco.com/docs/psirt/authentication/>.
|
||||
|
||||
### Microsoft MSRC (Concelier advisory + Excititor VEX — not yet wired for either)
|
||||
### Microsoft MSRC (Concelier advisory + Excititor VEX)
|
||||
|
||||
**What Stella Ops needs**: a Microsoft Entra confidential client app with `SecurityUpdates.Read.All` API permission.
|
||||
**What Stella Ops needs**: a Microsoft Entra confidential client app with the consent and scope required by your MSRC onboarding flow.
|
||||
|
||||
Steps:
|
||||
1. <https://entra.microsoft.com/> → **App registrations** → **New registration**.
|
||||
2. Name: `stella-ops-concelier-msrc`. Single-tenant. Redirect URI blank.
|
||||
3. From Overview: copy **Directory (tenant) ID** + **Application (client) ID**.
|
||||
4. **Certificates & secrets** → **New client secret** → 24-month expiry → copy the `Value` column **immediately**.
|
||||
5. **API permissions** → **Add a permission** → Security Updates API (App ID `83b40db2-0d04-4b56-9e77-0e7d76a47d4b`) → Application permissions → `SecurityUpdates.Read.All` → Grant admin consent.
|
||||
5. Grant the application permissions and consent required by your MSRC onboarding process before storing the values in Stella Ops.
|
||||
|
||||
Microsoft refs: <https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app>, <https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials>.
|
||||
|
||||
|
||||
@@ -97,6 +97,27 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var configurationFailure = await GetConfigurationFailureAsync(handle.Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (configurationFailure is not null)
|
||||
{
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
results.Add(new InitProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
handle.Descriptor.DisplayName,
|
||||
"blocked",
|
||||
stopwatch.Elapsed,
|
||||
configurationFailure.Message));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Excititor init blocked for provider {ProviderId} ({ErrorCode}): {Message}",
|
||||
handle.Descriptor.Id,
|
||||
configurationFailure.ErrorCode,
|
||||
configurationFailure.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
@@ -215,7 +236,7 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
|
||||
results.Add(new ReconcileProviderResult(
|
||||
handle.Descriptor.Id,
|
||||
result.Status,
|
||||
"reconciled",
|
||||
string.Equals(result.Status, "blocked", StringComparison.OrdinalIgnoreCase) ? "blocked" : "reconciled",
|
||||
result.LastUpdated ?? result.CompletedAt,
|
||||
threshold,
|
||||
result.Documents,
|
||||
@@ -328,6 +349,34 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
|
||||
|
||||
try
|
||||
{
|
||||
var configurationFailure = await GetConfigurationFailureAsync(providerId, cancellationToken).ConfigureAwait(false);
|
||||
if (configurationFailure is not null)
|
||||
{
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
var blockedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Excititor ingest provider {ProviderId} is blocked by persisted configuration ({ErrorCode}): {Message}",
|
||||
providerId,
|
||||
configurationFailure.ErrorCode,
|
||||
configurationFailure.Message);
|
||||
|
||||
return new ProviderRunResult(
|
||||
providerId,
|
||||
"blocked",
|
||||
0,
|
||||
0,
|
||||
startedAt,
|
||||
blockedAt,
|
||||
stopwatch.Elapsed,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
configurationFailure.Message,
|
||||
since);
|
||||
}
|
||||
|
||||
await ValidateConnectorAsync(handle, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureProviderRegistrationAsync(handle.Descriptor, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -439,6 +488,13 @@ internal sealed class VexIngestOrchestrator : IVexIngestOrchestrator
|
||||
}
|
||||
}
|
||||
|
||||
private Task<VexProviderConfigurationFailure?> GetConfigurationFailureAsync(
|
||||
string providerId,
|
||||
CancellationToken cancellationToken)
|
||||
=> _configurationService.SupportsConfiguration(providerId)
|
||||
? _configurationService.GetConfigurationFailureAsync(providerId, cancellationToken)
|
||||
: Task.FromResult<VexProviderConfigurationFailure?>(null);
|
||||
|
||||
private async Task<DateTimeOffset?> ResolveResumeSinceAsync(string providerId, string? checkpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(checkpoint))
|
||||
@@ -547,6 +603,7 @@ internal sealed record InitSummary(
|
||||
public int ProviderCount => Providers.Length;
|
||||
public int SuccessCount => Providers.Count(result => string.Equals(result.Status, "succeeded", StringComparison.OrdinalIgnoreCase));
|
||||
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
public int BlockedCount => Providers.Count(result => string.Equals(result.Status, "blocked", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal sealed record InitProviderResult(
|
||||
@@ -568,6 +625,8 @@ internal sealed record IngestRunSummary(
|
||||
|
||||
public int FailureCount => Providers.Count(provider => string.Equals(provider.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int BlockedCount => Providers.Count(provider => string.Equals(provider.Status, "blocked", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public TimeSpan Duration => CompletedAt - StartedAt;
|
||||
}
|
||||
|
||||
@@ -601,6 +660,8 @@ internal sealed record ReconcileSummary(
|
||||
|
||||
public int SkippedCount => Providers.Count(result => string.Equals(result.Action, "skipped", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int BlockedCount => Providers.Count(result => string.Equals(result.Action, "blocked", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public int FailureCount => Providers.Count(result => string.Equals(result.Status, "failed", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public TimeSpan Duration => CompletedAt - StartedAt;
|
||||
|
||||
@@ -3,6 +3,9 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Orchestration;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
@@ -12,7 +15,9 @@ using StellaOps.Excititor.Worker.Signature;
|
||||
using StellaOps.Plugin;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
@@ -22,6 +27,9 @@ namespace StellaOps.Excititor.Worker.Scheduling;
|
||||
|
||||
internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
{
|
||||
private const string ProviderConfigRequiredErrorCode = "PROVIDER_CONFIG_REQUIRED";
|
||||
private const string ProviderConfigInvalidErrorCode = "PROVIDER_CONFIG_INVALID";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly PluginCatalog _pluginCatalog;
|
||||
private readonly IVexWorkerOrchestratorClient _orchestratorClient;
|
||||
@@ -97,11 +105,6 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
connector.Id,
|
||||
settings ?? VexConnectorSettings.Empty,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rawStore = scopeProvider.GetRequiredService<IVexRawStore>();
|
||||
var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>();
|
||||
var stateRepository = scopeProvider.GetRequiredService<IVexConnectorStateRepository>();
|
||||
var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>();
|
||||
var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>();
|
||||
|
||||
var descriptor = connector switch
|
||||
{
|
||||
@@ -109,6 +112,11 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
_ => new VexConnectorDescriptor(connector.Id, VexProviderKind.Vendor, connector.Id)
|
||||
};
|
||||
|
||||
var rawStore = scopeProvider.GetRequiredService<IVexRawStore>();
|
||||
var providerStore = scopeProvider.GetRequiredService<IVexProviderStore>();
|
||||
var stateRepository = scopeProvider.GetRequiredService<IVexConnectorStateRepository>();
|
||||
var normalizerRouter = scopeProvider.GetRequiredService<IVexNormalizerRouter>();
|
||||
var signatureVerifier = scopeProvider.GetRequiredService<IVexSignatureVerifier>();
|
||||
var provider = await providerStore.FindAsync(descriptor.Id, cancellationToken).ConfigureAwait(false)
|
||||
?? new VexProvider(descriptor.Id, descriptor.DisplayName, descriptor.Kind);
|
||||
|
||||
@@ -117,6 +125,23 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
var stateBeforeRun = await stateRepository.GetAsync(descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var configurationBlock = GetConfigurationBlock(connector.Id, effectiveSettings);
|
||||
if (configurationBlock is not null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Connector {ConnectorId} is blocked by persisted configuration ({ErrorCode}): {Message}",
|
||||
connector.Id,
|
||||
configurationBlock.ErrorCode,
|
||||
configurationBlock.Message);
|
||||
|
||||
await UpdateConfigurationBlockedStateAsync(
|
||||
stateRepository,
|
||||
descriptor.Id,
|
||||
configurationBlock.Message,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateBeforeRun?.NextEligibleRun is { } nextEligible && nextEligible > now)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
@@ -303,6 +328,167 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
return new VexConnectorSettings(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private static ProviderConfigurationBlock? GetConfigurationBlock(string providerId, VexConnectorSettings settings)
|
||||
{
|
||||
var values = settings?.Values ?? ImmutableDictionary<string, string>.Empty;
|
||||
return providerId switch
|
||||
{
|
||||
"excititor:cisco" => ValidateCisco(values),
|
||||
"excititor:suse-rancher" => ValidateSuseRancher(values),
|
||||
"excititor:msrc" => ValidateMsrc(values),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static ProviderConfigurationBlock? ValidateCisco(IReadOnlyDictionary<string, string> settings)
|
||||
{
|
||||
var options = new CiscoConnectorOptions();
|
||||
if (TryGetTrimmed(settings, "metadataUri", out var metadataUri))
|
||||
{
|
||||
options.MetadataUri = metadataUri;
|
||||
}
|
||||
|
||||
if (settings.TryGetValue("apiToken", out var apiToken))
|
||||
{
|
||||
options.ApiToken = apiToken;
|
||||
}
|
||||
|
||||
var context = new ValidationContext(options);
|
||||
var errors = options.Validate(context)
|
||||
.Select(static result => result.ErrorMessage ?? string.Empty)
|
||||
.Where(static message => !string.IsNullOrWhiteSpace(message))
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? null
|
||||
: new ProviderConfigurationBlock(
|
||||
ProviderConfigInvalidErrorCode,
|
||||
$"Cisco CSAF persisted configuration is invalid: {string.Join("; ", errors)}");
|
||||
}
|
||||
|
||||
private static ProviderConfigurationBlock? ValidateSuseRancher(IReadOnlyDictionary<string, string> settings)
|
||||
{
|
||||
var options = new RancherHubConnectorOptions();
|
||||
if (TryGetUri(settings, "discoveryUri", out var discoveryUri))
|
||||
{
|
||||
options.DiscoveryUri = discoveryUri;
|
||||
}
|
||||
|
||||
if (TryGetUri(settings, "tokenEndpoint", out var tokenEndpoint))
|
||||
{
|
||||
options.TokenEndpoint = tokenEndpoint;
|
||||
}
|
||||
|
||||
if (TryGetTrimmed(settings, "clientId", out var clientId))
|
||||
{
|
||||
options.ClientId = clientId;
|
||||
}
|
||||
|
||||
if (TryGetTrimmed(settings, "clientSecret", out var clientSecret))
|
||||
{
|
||||
options.ClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
if (TryGetTrimmed(settings, "audience", out var audience))
|
||||
{
|
||||
options.Audience = audience;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ProviderConfigurationBlock(
|
||||
ProviderConfigInvalidErrorCode,
|
||||
$"SUSE Rancher VEX Hub persisted configuration is invalid: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ProviderConfigurationBlock? ValidateMsrc(IReadOnlyDictionary<string, string> settings)
|
||||
{
|
||||
var tenantId = GetTrimmed(settings, "tenantId");
|
||||
var clientId = GetTrimmed(settings, "clientId");
|
||||
var clientSecret = GetTrimmed(settings, "clientSecret");
|
||||
|
||||
var missing = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
missing.Add("tenantId");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
missing.Add("clientId");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientSecret))
|
||||
{
|
||||
missing.Add("clientSecret");
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return new ProviderConfigurationBlock(
|
||||
ProviderConfigRequiredErrorCode,
|
||||
"Microsoft MSRC CSAF requires Azure tenant ID, client ID, and client secret before sync can run.");
|
||||
}
|
||||
|
||||
var options = new MsrcConnectorOptions
|
||||
{
|
||||
TenantId = tenantId!,
|
||||
ClientId = clientId!,
|
||||
ClientSecret = clientSecret,
|
||||
};
|
||||
|
||||
if (TryGetTrimmed(settings, "scope", out var scope))
|
||||
{
|
||||
options.Scope = scope;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new ProviderConfigurationBlock(
|
||||
ProviderConfigInvalidErrorCode,
|
||||
$"MSRC persisted configuration is invalid: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetTrimmed(IReadOnlyDictionary<string, string> settings, string key)
|
||||
=> TryGetTrimmed(settings, key, out var value) ? value : null;
|
||||
|
||||
private static bool TryGetTrimmed(IReadOnlyDictionary<string, string> settings, string key, out string value)
|
||||
{
|
||||
if (settings.TryGetValue(key, out var current) && !string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
value = current.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetUri(IReadOnlyDictionary<string, string> settings, string key, out Uri value)
|
||||
{
|
||||
if (TryGetTrimmed(settings, key, out var current) &&
|
||||
Uri.TryCreate(current, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task SafeWaitForTaskAsync(Task task)
|
||||
{
|
||||
try
|
||||
@@ -335,6 +521,25 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateConfigurationBlockedStateAsync(
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
string connectorId,
|
||||
string message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await stateRepository.GetAsync(connectorId, cancellationToken).ConfigureAwait(false)
|
||||
?? new VexConnectorState(connectorId, null, ImmutableArray<string>.Empty);
|
||||
|
||||
var updated = current with
|
||||
{
|
||||
FailureCount = 0,
|
||||
NextEligibleRun = null,
|
||||
LastFailureReason = Truncate(message, 512)
|
||||
};
|
||||
|
||||
await stateRepository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateFailureStateAsync(
|
||||
IVexConnectorStateRepository stateRepository,
|
||||
string connectorId,
|
||||
@@ -441,4 +646,8 @@ internal sealed class DefaultVexProviderRunner : IVexProviderRunner
|
||||
? value
|
||||
: value[..maxLength];
|
||||
}
|
||||
|
||||
private sealed record ProviderConfigurationBlock(
|
||||
string ErrorCode,
|
||||
string Message);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<Compile Include="OpenApiDiscoveryEndpointTests.cs" />
|
||||
<Compile Include="PolicyEndpointsTests.cs" />
|
||||
<Compile Include="ProviderManagementEndpointsTests.cs" />
|
||||
<Compile Include="VexIngestOrchestratorTests.cs" />
|
||||
<Compile Include="VexProviderConfigurationServiceTests.cs" />
|
||||
<Compile Include="AttestationStoreWiringTests.cs" />
|
||||
<Compile Include="TenantIsolationTests.cs" />
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0328-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.WebService.Tests. |
|
||||
| AUDIT-0328-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REALPLAN-007-B | DONE | 2026-04-15: Added host wiring proof that Excititor.WebService resolves `IGraphOverlayStore` to `PostgresGraphOverlayStore` instead of the in-memory fallback. |
|
||||
| EXCITITOR-CFG-02-FOLLOWUP | DONE | 2026-04-22: Added deterministic negative-path coverage proving batch ingest returns `blocked` for providers missing persisted credentials instead of surfacing a generic runtime failure. |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class VexIngestOrchestratorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsBlocked_WhenProviderConfigurationMissing()
|
||||
{
|
||||
var connector = new RecordingConnector("excititor:msrc", VexProviderKind.Vendor);
|
||||
var settingsStore = new InMemoryVexProviderSettingsStore();
|
||||
var runtimeSettingsCache = new VexProviderRuntimeSettingsCache();
|
||||
var configurationService = new VexProviderConfigurationService(
|
||||
settingsStore,
|
||||
runtimeSettingsCache,
|
||||
TimeProvider.System,
|
||||
NullLogger<VexProviderConfigurationService>.Instance);
|
||||
|
||||
var orchestrator = new VexIngestOrchestrator(
|
||||
new ServiceCollection().BuildServiceProvider(),
|
||||
new[] { connector },
|
||||
new InMemoryVexRawStore(),
|
||||
new InMemoryVexClaimStore(),
|
||||
new InMemoryVexProviderStore(),
|
||||
new InMemoryVexConnectorStateRepository(),
|
||||
new NoopNormalizerRouter(),
|
||||
new NoopSignatureVerifier(),
|
||||
configurationService,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(new VexStorageOptions()),
|
||||
NullLogger<VexIngestOrchestrator>.Instance);
|
||||
|
||||
var summary = await orchestrator.RunAsync(
|
||||
new IngestRunOptions(ImmutableArray.Create("excititor:msrc"), Since: null, Window: null, Force: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var result = Assert.Single(summary.Providers);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
Assert.Contains("Azure tenant ID", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(connector.ValidateInvoked);
|
||||
Assert.False(connector.FetchInvoked);
|
||||
}
|
||||
|
||||
private sealed class RecordingConnector : IVexConnector
|
||||
{
|
||||
public RecordingConnector(string id, VexProviderKind kind)
|
||||
{
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public VexProviderKind Kind { get; }
|
||||
|
||||
public bool ValidateInvoked { get; private set; }
|
||||
|
||||
public bool FetchInvoked { get; private set; }
|
||||
|
||||
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateInvoked = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<VexRawDocument> FetchAsync(
|
||||
VexConnectorContext context,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
FetchInvoked = true;
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(
|
||||
document,
|
||||
ImmutableArray<VexClaim>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(
|
||||
document,
|
||||
ImmutableArray<VexClaim>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,45 @@ public sealed class DefaultVexProviderRunnerTests
|
||||
state.NextEligibleRun.Should().Be(now + TimeSpan.FromHours(12));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ConfigBlocked_DoesNotFetch_AndClearsBackoff()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 10, 21, 17, 30, 0, TimeSpan.Zero);
|
||||
var time = new FixedTimeProvider(now);
|
||||
var connector = TestConnector.Success("excititor:msrc");
|
||||
var stateRepository = new InMemoryStateRepository();
|
||||
stateRepository.Save(new VexConnectorState(
|
||||
"excititor:msrc",
|
||||
LastUpdated: now.AddDays(-2),
|
||||
DocumentDigests: ImmutableArray<string>.Empty,
|
||||
ResumeTokens: ImmutableDictionary<string, string>.Empty,
|
||||
LastSuccessAt: now.AddDays(-1),
|
||||
FailureCount: 3,
|
||||
NextEligibleRun: now.AddHours(2),
|
||||
LastFailureReason: "stale failure"));
|
||||
|
||||
var services = CreateServiceProvider(connector, stateRepository);
|
||||
var runner = CreateRunner(services, time, options =>
|
||||
{
|
||||
options.Retry.BaseDelay = TimeSpan.FromMinutes(5);
|
||||
options.Retry.MaxDelay = TimeSpan.FromMinutes(60);
|
||||
options.Retry.QuarantineDuration = TimeSpan.FromHours(12);
|
||||
options.Retry.JitterRatio = 0;
|
||||
});
|
||||
|
||||
await runner.RunAsync(
|
||||
new VexWorkerSchedule(connector.Id, TimeSpan.FromMinutes(10), TimeSpan.Zero, EmptySettings),
|
||||
CancellationToken.None);
|
||||
|
||||
connector.FetchInvoked.Should().BeFalse();
|
||||
var state = stateRepository.Get("excititor:msrc");
|
||||
state.Should().NotBeNull();
|
||||
state!.FailureCount.Should().Be(0);
|
||||
state.NextEligibleRun.Should().BeNull();
|
||||
state.LastFailureReason.Should().Contain("requires Azure tenant ID");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeDeterministicSample_IsStable()
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0330-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.Worker.Tests. |
|
||||
| AUDIT-0330-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.Worker.Tests. |
|
||||
| AUDIT-0330-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| EXCITITOR-CFG-02-FOLLOWUP | DONE | 2026-04-22: Added deterministic negative-path coverage proving Worker skips persisted-config-blocked providers without fetch or retry/backoff escalation. |
|
||||
|
||||
Reference in New Issue
Block a user