Add integration connector plugins and compose fixtures
Scaffold connector plugins for DockerRegistry, GitLab, Gitea, Jenkins, and Nexus. Wire plugin discovery in IntegrationService and add compose fixtures for local integration testing. - 5 new connector plugins under src/Integrations/__Plugins/ - docker-compose.integrations.yml for local fixture services - Advisory source catalog and source management API updates - Integration e2e test specs and Playwright config - Integration hub docs under docs/integrations/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,21 +54,67 @@ public sealed class LoggingAuditLogger : IIntegrationAuditLogger
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub AuthRef resolver for development.
|
||||
/// Development AuthRef resolver that supports HashiCorp Vault for local integration testing.
|
||||
/// In production, integrate with Authority service.
|
||||
/// URI format: authref://vault/{path}#{key}
|
||||
/// </summary>
|
||||
public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
{
|
||||
private readonly ILogger<StubAuthRefResolver> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly string _vaultAddr;
|
||||
private readonly string _vaultToken;
|
||||
|
||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger)
|
||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR") ?? "http://vault.stella-ops.local:8200";
|
||||
_vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "stellaops-dev-root-token-2026";
|
||||
}
|
||||
|
||||
public Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
|
||||
public async Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning("StubAuthRefResolver: Would resolve {AuthRefUri} - returning null in dev mode", authRefUri);
|
||||
return Task.FromResult<string?>(null);
|
||||
if (string.IsNullOrEmpty(authRefUri))
|
||||
return null;
|
||||
|
||||
// Parse authref://vault/{path}#{key}
|
||||
if (authRefUri.StartsWith("authref://vault/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var remainder = authRefUri["authref://vault/".Length..];
|
||||
var hashIndex = remainder.IndexOf('#');
|
||||
var path = hashIndex >= 0 ? remainder[..hashIndex] : remainder;
|
||||
var key = hashIndex >= 0 ? remainder[(hashIndex + 1)..] : "value";
|
||||
|
||||
var client = _httpClientFactory.CreateClient("VaultClient");
|
||||
client.BaseAddress = new Uri(_vaultAddr);
|
||||
client.DefaultRequestHeaders.Add("X-Vault-Token", _vaultToken);
|
||||
|
||||
var response = await client.GetAsync($"/v1/secret/data/{path}", cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
if (doc.RootElement.TryGetProperty("data", out var dataWrap) &&
|
||||
dataWrap.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty(key, out var secretValue))
|
||||
{
|
||||
_logger.LogInformation("Resolved authref from Vault: {Path}#{Key}", path, key);
|
||||
return secretValue.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Vault lookup failed for {AuthRefUri}: {StatusCode}", authRefUri, response.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Vault resolution failed for {AuthRefUri}", authRefUri);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("AuthRef not resolved: {AuthRefUri}", authRefUri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,33 @@ public sealed class IntegrationService
|
||||
|
||||
_logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
|
||||
|
||||
// Auto-test connection when a plugin is available
|
||||
var plugin = _pluginLoader.GetByProvider(created.Provider);
|
||||
if (plugin is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolvedSecret = created.AuthRefUri is not null
|
||||
? await _authRefResolver.ResolveAsync(created.AuthRefUri, cancellationToken)
|
||||
: null;
|
||||
var config = BuildConfig(created, resolvedSecret);
|
||||
var testResult = await plugin.TestConnectionAsync(config, cancellationToken);
|
||||
|
||||
var newStatus = testResult.Success ? IntegrationStatus.Active : IntegrationStatus.Failed;
|
||||
if (created.Status != newStatus)
|
||||
{
|
||||
created.Status = newStatus;
|
||||
created.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
await _repository.UpdateAsync(created, cancellationToken);
|
||||
_logger.LogInformation("Auto-test set integration {Id} to {Status}", created.Id, newStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Auto test-connection failed for integration {Id}, leaving as Pending", created.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return MapToResponse(created);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.Plugin.GitHubApp;
|
||||
using StellaOps.Integrations.Plugin.Harbor;
|
||||
using StellaOps.Integrations.Plugin.InMemory;
|
||||
using StellaOps.Integrations.Plugin.Gitea;
|
||||
using StellaOps.Integrations.Plugin.Jenkins;
|
||||
using StellaOps.Integrations.Plugin.Nexus;
|
||||
using StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
using StellaOps.Integrations.Plugin.GitLab;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using StellaOps.Integrations.WebService.AiCodeGuard;
|
||||
using StellaOps.Integrations.WebService.Infrastructure;
|
||||
@@ -46,6 +51,9 @@ builder.Services.AddStartupMigrations(
|
||||
// Repository
|
||||
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
||||
|
||||
// HttpClient factory (used by AuthRef resolver for Vault)
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Plugin loader
|
||||
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
{
|
||||
@@ -67,7 +75,12 @@ builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
typeof(Program).Assembly,
|
||||
typeof(GitHubAppConnectorPlugin).Assembly,
|
||||
typeof(HarborConnectorPlugin).Assembly,
|
||||
typeof(InMemoryConnectorPlugin).Assembly
|
||||
typeof(InMemoryConnectorPlugin).Assembly,
|
||||
typeof(GiteaConnectorPlugin).Assembly,
|
||||
typeof(JenkinsConnectorPlugin).Assembly,
|
||||
typeof(NexusConnectorPlugin).Assembly,
|
||||
typeof(DockerRegistryConnectorPlugin).Assembly,
|
||||
typeof(GitLabConnectorPlugin).Assembly
|
||||
]);
|
||||
|
||||
return loader;
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Gitea\StellaOps.Integrations.Plugin.Gitea.csproj" />
|
||||
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Jenkins\StellaOps.Integrations.Plugin.Jenkins.csproj" />
|
||||
<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="..\..\__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" />
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry (OCI Distribution) connector plugin.
|
||||
/// Supports any OCI Distribution Spec-compliant registry (Docker Hub, self-hosted registry:2, etc.).
|
||||
/// </summary>
|
||||
public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DockerRegistryConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public DockerRegistryConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "docker-registry";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.DockerHub;
|
||||
|
||||
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
|
||||
{
|
||||
// OCI Distribution Spec: GET /v2/ returns 200 {} when authenticated
|
||||
var response = await client.GetAsync("/v2/", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Docker Registry connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["apiVersion"] = response.Headers.TryGetValues("Docker-Distribution-Api-Version", out var versions)
|
||||
? versions.FirstOrDefault() ?? "unknown"
|
||||
: "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Docker Registry 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
|
||||
{
|
||||
// Check /v2/_catalog to verify registry is fully operational
|
||||
var response = await client.GetAsync("/v2/_catalog", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var catalog = JsonSerializer.Deserialize<DockerCatalogResponse>(content, JsonOptions);
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: "Docker Registry is available",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["repositories"] = (catalog?.Repositories?.Count ?? 0).ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Docker Registry 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"));
|
||||
|
||||
// Docker Registry uses Bearer token authentication if provided
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Docker Registry API DTOs ────────────────────────────────────
|
||||
|
||||
private sealed class DockerCatalogResponse
|
||||
{
|
||||
public List<string>? Repositories { 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.DockerRegistry</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,165 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitLab;
|
||||
|
||||
/// <summary>
|
||||
/// GitLab Server SCM connector plugin.
|
||||
/// Supports GitLab v4 API (self-managed instances).
|
||||
/// </summary>
|
||||
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public GitLabConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public GitLabConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "gitlab-server";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.GitLabServer;
|
||||
|
||||
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/v4/version", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var versionInfo = JsonSerializer.Deserialize<GitLabVersionResponse>(content, JsonOptions);
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "GitLab connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["version"] = versionInfo?.Version ?? "unknown",
|
||||
["revision"] = versionInfo?.Revision ?? "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"GitLab 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/v4/version", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var versionInfo = JsonSerializer.Deserialize<GitLabVersionResponse>(content, JsonOptions);
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: $"GitLab is running version {versionInfo?.Version ?? "unknown"}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = versionInfo?.Version ?? "unknown",
|
||||
["revision"] = versionInfo?.Revision ?? "unknown"
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"GitLab 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"));
|
||||
|
||||
// GitLab uses PRIVATE-TOKEN header for authentication
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("PRIVATE-TOKEN", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── GitLab API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class GitLabVersionResponse
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
public string? Revision { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Gitea;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea SCM connector plugin.
|
||||
/// Supports Gitea v1.x API.
|
||||
/// </summary>
|
||||
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public GiteaConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public GiteaConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "gitea";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Gitea;
|
||||
|
||||
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/version", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var versionInfo = JsonSerializer.Deserialize<GiteaVersionResponse>(content, JsonOptions);
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Gitea connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["version"] = versionInfo?.Version ?? "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Gitea 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/version", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var versionInfo = JsonSerializer.Deserialize<GiteaVersionResponse>(content, JsonOptions);
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: $"Gitea is running version {versionInfo?.Version ?? "unknown"}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = versionInfo?.Version ?? "unknown"
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Gitea 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"));
|
||||
|
||||
// Gitea uses token-based authentication: Authorization: token <secret>
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Gitea API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class GiteaVersionResponse
|
||||
{
|
||||
public string? Version { 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.Gitea</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,177 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Jenkins;
|
||||
|
||||
/// <summary>
|
||||
/// Jenkins CI/CD connector plugin.
|
||||
/// Supports Jenkins REST API.
|
||||
/// </summary>
|
||||
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public JenkinsConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public JenkinsConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "jenkins";
|
||||
|
||||
public IntegrationType Type => IntegrationType.CiCd;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Jenkins;
|
||||
|
||||
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/json", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var info = JsonSerializer.Deserialize<JenkinsInfoResponse>(content, JsonOptions);
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Jenkins connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["mode"] = info?.Mode ?? "unknown",
|
||||
["nodeDescription"] = info?.NodeDescription ?? "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Jenkins 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/json", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var info = JsonSerializer.Deserialize<JenkinsInfoResponse>(content, JsonOptions);
|
||||
|
||||
// Jenkins in QUIET mode is shutting down — treat as degraded
|
||||
var status = info?.Mode switch
|
||||
{
|
||||
"NORMAL" => HealthStatus.Healthy,
|
||||
"QUIET" => HealthStatus.Degraded,
|
||||
_ => HealthStatus.Unhealthy
|
||||
};
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: status,
|
||||
Message: $"Jenkins mode: {info?.Mode ?? "unknown"}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["mode"] = info?.Mode ?? "unknown",
|
||||
["nodeDescription"] = info?.NodeDescription ?? "unknown",
|
||||
["numExecutors"] = info?.NumExecutors.ToString() ?? "0"
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Jenkins 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"));
|
||||
|
||||
// Jenkins uses Basic auth (username:apiToken or username:password)
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Jenkins API DTOs ────────────────────────────────────────────
|
||||
|
||||
private sealed class JenkinsInfoResponse
|
||||
{
|
||||
public string? Mode { get; set; }
|
||||
public string? NodeDescription { get; set; }
|
||||
public int NumExecutors { 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.Jenkins</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,156 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Nexus;
|
||||
|
||||
/// <summary>
|
||||
/// Sonatype Nexus Repository Manager connector plugin.
|
||||
/// Supports Nexus Repository Manager 3.x REST API.
|
||||
/// </summary>
|
||||
public sealed class NexusConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NexusConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public NexusConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Name => "nexus";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Nexus;
|
||||
|
||||
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("/service/rest/v1/status", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "Nexus connection successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Nexus 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("/service/rest/v1/status", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: "Nexus is available and ready",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
CheckedAt: _timeProvider.GetUtcNow(),
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
// Nexus returns 503 when starting up
|
||||
var status = (int)response.StatusCode == 503
|
||||
? HealthStatus.Degraded
|
||||
: HealthStatus.Unhealthy;
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: status,
|
||||
Message: $"Nexus 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"));
|
||||
|
||||
// Nexus uses Basic auth (username:password)
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
@@ -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.Nexus</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user