Add Vault, Consul, eBPF connector plugins and thorough integration e2e tests

Backend:
- Add SecretsManager=9 type, Vault=550 and Consul=551 providers to IntegrationEnums
- Create VaultConnectorPlugin (GET /v1/sys/health), ConsulConnectorPlugin
  (GET /v1/status/leader), EbpfAgentConnectorPlugin (GET /api/v1/health)
- Register all 3 plugins in Program.cs and WebService.csproj
- Extend Concelier JobRegistrationExtensions with 20 additional advisory
  source connectors (ghsa, kev, epss, debian, ubuntu, alpine, suse, etc.)
- Add connector project references to Concelier WebService.csproj so
  Type.GetType() can resolve job classes at runtime
- Fix job kind names to match SourceDefinitions IDs (jpcert not jvn,
  oracle not vndr-oracle, etc.)

Infrastructure:
- Add Consul service to docker-compose.integrations.yml (127.1.2.8:8500)
- Add runtime-host nginx fixture to docker-compose.integration-fixtures.yml
  (127.1.1.9:80)

Frontend:
- Mirror SecretsManager/Vault/Consul enum additions in integration.models.ts
- Fix Secrets tab route type from RepoSource to SecretsManager
- Add SecretsManager to parseType() and TYPE_DISPLAY_NAMES

E2E tests (117/117 passing):
- vault-consul-secrets.e2e.spec.ts: compose health, probes, CRUD, UI
- runtime-hosts.e2e.spec.ts: fixture probe, CRUD, hosts tab
- advisory-sync.e2e.spec.ts: 21 sources sync accepted, catalog, management
- ui-onboarding-wizard.e2e.spec.ts: wizard steps for registry/scm/ci
- ui-integration-detail.e2e.spec.ts: detail tabs, health data
- ui-crud-operations.e2e.spec.ts: search, sort, delete
- helpers.ts: shared configs, API helpers, screenshot util
- Updated playwright.integrations.config.ts with reporter and CI retries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-31 14:39:08 +03:00
parent 4a570b2842
commit 2fef38b093
25 changed files with 2091 additions and 140 deletions

View File

@@ -10,6 +10,9 @@ using StellaOps.Integrations.Plugin.Jenkins;
using StellaOps.Integrations.Plugin.Nexus;
using StellaOps.Integrations.Plugin.DockerRegistry;
using StellaOps.Integrations.Plugin.GitLab;
using StellaOps.Integrations.Plugin.Vault;
using StellaOps.Integrations.Plugin.Consul;
using StellaOps.Integrations.Plugin.EbpfAgent;
using StellaOps.Integrations.WebService;
using StellaOps.Integrations.WebService.AiCodeGuard;
using StellaOps.Integrations.WebService.Infrastructure;
@@ -80,7 +83,10 @@ builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
typeof(JenkinsConnectorPlugin).Assembly,
typeof(NexusConnectorPlugin).Assembly,
typeof(DockerRegistryConnectorPlugin).Assembly,
typeof(GitLabConnectorPlugin).Assembly
typeof(GitLabConnectorPlugin).Assembly,
typeof(VaultConnectorPlugin).Assembly,
typeof(ConsulConnectorPlugin).Assembly,
typeof(EbpfAgentConnectorPlugin).Assembly
]);
return loader;

View File

@@ -22,6 +22,9 @@
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Nexus\StellaOps.Integrations.Plugin.Nexus.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.DockerRegistry\StellaOps.Integrations.Plugin.DockerRegistry.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitLab\StellaOps.Integrations.Plugin.GitLab.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Vault\StellaOps.Integrations.Plugin.Vault.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Consul\StellaOps.Integrations.Plugin.Consul.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.EbpfAgent\StellaOps.Integrations.Plugin.EbpfAgent.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />

View File

@@ -27,7 +27,10 @@ public enum IntegrationType
SymbolSource = 7,
/// <summary>Remediation marketplace source (community, partner, vendor fix templates).</summary>
Marketplace = 8
Marketplace = 8,
/// <summary>Secrets/config management (Vault, Consul, etc.).</summary>
SecretsManager = 9
}
/// <summary>
@@ -93,6 +96,10 @@ public enum IntegrationProvider
PartnerFixes = 801,
VendorFixes = 802,
// Secrets / config managers
Vault = 550,
Consul = 551,
// Generic / testing
InMemory = 900,
Custom = 999

View File

@@ -0,0 +1,180 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
namespace StellaOps.Integrations.Plugin.Consul;
/// <summary>
/// HashiCorp Consul connector plugin.
/// Supports Consul HTTP API v1 for service discovery and KV configuration.
/// </summary>
public sealed class ConsulConnectorPlugin : IIntegrationConnectorPlugin
{
private readonly TimeProvider _timeProvider;
public ConsulConnectorPlugin()
: this(TimeProvider.System)
{
}
public ConsulConnectorPlugin(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Name => "consul";
public IntegrationType Type => IntegrationType.SecretsManager;
public IntegrationProvider Provider => IntegrationProvider.Consul;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
// Call Consul leader status endpoint
var response = await client.GetAsync("/v1/status/leader", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
// Consul returns a quoted string like "127.0.0.1:8300" when healthy
var isHealthy = !string.IsNullOrWhiteSpace(content) && content.StartsWith('"') && content.Length > 2;
var leaderAddress = content.Trim('"');
return new TestConnectionResult(
Success: isHealthy,
Message: isHealthy ? "Consul connection successful" : "Consul returned empty leader",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["leader"] = leaderAddress
},
Duration: duration);
}
return new TestConnectionResult(
Success: false,
Message: $"Consul returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString()
},
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/v1/agent/self", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var agentSelf = JsonSerializer.Deserialize<ConsulAgentSelfResponse>(content, JsonOptions);
return new HealthCheckResult(
Status: HealthStatus.Healthy,
Message: $"Consul agent healthy: {agentSelf?.Config?.NodeName ?? "unknown"}",
Details: new Dictionary<string, string>
{
["nodeName"] = agentSelf?.Config?.NodeName ?? "unknown",
["datacenter"] = agentSelf?.Config?.Datacenter ?? "unknown"
},
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Consul returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
{
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Add Consul ACL token if secret is provided
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Add("X-Consul-Token", config.ResolvedSecret);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
// -- Consul API DTOs ────────────────────────────────────────────
private sealed class ConsulAgentSelfResponse
{
public ConsulConfig? Config { get; set; }
public ConsulMember? Member { get; set; }
}
private sealed class ConsulConfig
{
public string? NodeName { get; set; }
public string? Datacenter { get; set; }
}
private sealed class ConsulMember
{
public int Status { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.Consul</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,182 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
namespace StellaOps.Integrations.Plugin.EbpfAgent;
/// <summary>
/// eBPF runtime host agent connector plugin.
/// Connects to an eBPF-based telemetry agent running on a runtime host.
/// </summary>
public sealed class EbpfAgentConnectorPlugin : IIntegrationConnectorPlugin
{
private readonly TimeProvider _timeProvider;
public EbpfAgentConnectorPlugin()
: this(TimeProvider.System)
{
}
public EbpfAgentConnectorPlugin(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Name => "ebpf-agent";
public IntegrationType Type => IntegrationType.RuntimeHost;
public IntegrationProvider Provider => IntegrationProvider.EbpfAgent;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/api/v1/health", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<EbpfAgentHealthResponse>(content, JsonOptions);
return new TestConnectionResult(
Success: health?.Status == "healthy",
Message: health?.Status == "healthy" ? "eBPF agent connection successful" : $"eBPF agent unhealthy: {health?.Status}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["version"] = health?.Version ?? "unknown",
["agent"] = health?.Agent ?? "unknown",
["probes_loaded"] = health?.ProbesLoaded.ToString() ?? "0",
["events_per_second"] = health?.EventsPerSecond.ToString() ?? "0"
},
Duration: duration);
}
return new TestConnectionResult(
Success: false,
Message: $"eBPF agent returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString()
},
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/api/v1/health", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<EbpfAgentHealthResponse>(content, JsonOptions);
var status = health?.Status switch
{
"healthy" => HealthStatus.Healthy,
_ => HealthStatus.Degraded
};
return new HealthCheckResult(
Status: status,
Message: $"eBPF agent status: {health?.Status}",
Details: new Dictionary<string, string>
{
["agent"] = health?.Agent ?? "unknown",
["version"] = health?.Version ?? "unknown",
["kernel"] = health?.Kernel ?? "unknown",
["probes_loaded"] = health?.ProbesLoaded.ToString() ?? "0"
},
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"eBPF agent returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
{
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Add bearer auth if secret is provided
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
// ── eBPF Agent API DTOs ───────────────────────────────────────
private sealed class EbpfAgentHealthResponse
{
public string? Status { get; set; }
public string? Agent { get; set; }
public string? Version { get; set; }
public int ProbesLoaded { get; set; }
public int EventsPerSecond { get; set; }
public string? Kernel { get; set; }
public long UptimeSeconds { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.EbpfAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.Vault</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,189 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
namespace StellaOps.Integrations.Plugin.Vault;
/// <summary>
/// HashiCorp Vault secrets manager connector plugin.
/// Supports Vault HTTP API v1.
/// </summary>
public sealed class VaultConnectorPlugin : IIntegrationConnectorPlugin
{
private readonly TimeProvider _timeProvider;
public VaultConnectorPlugin()
: this(TimeProvider.System)
{
}
public VaultConnectorPlugin(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Name => "vault";
public IntegrationType Type => IntegrationType.SecretsManager;
public IntegrationProvider Provider => IntegrationProvider.Vault;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
// Call Vault sys/health endpoint
var response = await client.GetAsync("/v1/sys/health", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<VaultHealthResponse>(content, JsonOptions);
var initialized = health?.Initialized ?? false;
var sealed_ = health?.Sealed ?? true;
var success = initialized && !sealed_;
return new TestConnectionResult(
Success: success,
Message: success ? "Vault connection successful" : $"Vault not ready: initialized={initialized}, sealed={sealed_}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["version"] = health?.Version ?? "unknown",
["initialized"] = initialized.ToString().ToLowerInvariant(),
["sealed"] = sealed_.ToString().ToLowerInvariant()
},
Duration: duration);
}
return new TestConnectionResult(
Success: false,
Message: $"Vault returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString()
},
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/v1/sys/health", cancellationToken);
var duration = _timeProvider.GetUtcNow() - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<VaultHealthResponse>(content, JsonOptions);
var initialized = health?.Initialized ?? false;
var sealed_ = health?.Sealed ?? true;
var status = (initialized, sealed_) switch
{
(true, false) => HealthStatus.Healthy,
(true, true) => HealthStatus.Degraded,
_ => HealthStatus.Unhealthy
};
return new HealthCheckResult(
Status: status,
Message: $"Vault status: initialized={initialized}, sealed={sealed_}",
Details: new Dictionary<string, string>
{
["initialized"] = initialized.ToString().ToLowerInvariant(),
["sealed"] = sealed_.ToString().ToLowerInvariant(),
["version"] = health?.Version ?? "unknown",
["clusterName"] = health?.ClusterName ?? "unknown"
},
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Vault returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
catch (Exception ex)
{
var duration = _timeProvider.GetUtcNow() - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: _timeProvider.GetUtcNow(),
Duration: duration);
}
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
{
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Add Vault token auth if secret is provided
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Add("X-Vault-Token", config.ResolvedSecret);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
// ── Vault API DTOs ────────────────────────────────────────────
private sealed class VaultHealthResponse
{
public bool Initialized { get; set; }
public bool Sealed { get; set; }
public bool Standby { get; set; }
public string? Version { get; set; }
public string? ClusterName { get; set; }
public long ServerTimeUtc { get; set; }
}
}