using StellaOps.Integrations.Contracts; using StellaOps.Integrations.Core; using System.Net.Http.Headers; using System.Text.Json; namespace StellaOps.Integrations.Plugin.Consul; /// /// HashiCorp Consul connector plugin. /// Supports Consul HTTP API v1 for service discovery and KV configuration. /// 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 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 { ["endpoint"] = config.Endpoint, ["leader"] = leaderAddress }, Duration: duration); } return new TestConnectionResult( Success: false, Message: $"Consul returned {response.StatusCode}", Details: new Dictionary { ["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 { ["endpoint"] = config.Endpoint, ["error"] = ex.GetType().Name }, Duration: duration); } } public async Task 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(content, JsonOptions); return new HealthCheckResult( Status: HealthStatus.Healthy, Message: $"Consul agent healthy: {agentSelf?.Config?.NodeName ?? "unknown"}", Details: new Dictionary { ["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 { ["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 { ["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; } } }