Files
git.stella-ops.org/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Consul/ConsulConnectorPlugin.cs
master 2fef38b093 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>
2026-03-31 14:39:08 +03:00

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; }
}
}