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>
181 lines
6.4 KiB
C#
181 lines
6.4 KiB
C#
|
|
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; }
|
|
}
|
|
}
|