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