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:
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user