Add integration discovery, GitLab CI/Registry plugins, and CLI catalog command

Introduce IntegrationDiscovery DTOs, GitLabCiConnectorPlugin,
GitLabContainerRegistryConnectorPlugin, CLI integrations command group,
and expand impact/service test coverage for all connector plugins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-06 08:52:24 +03:00
parent 5d6435fdb2
commit 1cff9ef9cc
26 changed files with 3178 additions and 91 deletions

View File

@@ -137,6 +137,34 @@ public static class IntegrationEndpoints
.WithName("TestIntegrationConnection")
.WithDescription(_t("integrations.integration.test_description"));
// Discover resources
group.MapPost("/{id:guid}/discover", async (
[FromServices] IntegrationService service,
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
Guid id,
[FromBody] DiscoverIntegrationRequest request,
CancellationToken cancellationToken) =>
{
var result = await service.DiscoverAsync(id, request, tenantAccessor.TenantId, cancellationToken);
return result.Status switch
{
DiscoveryExecutionStatus.Success => Results.Ok(result.Response),
DiscoveryExecutionStatus.IntegrationNotFound => Results.NotFound(),
DiscoveryExecutionStatus.Unsupported => Results.BadRequest(new
{
error = result.Message,
supportedResourceTypes = result.SupportedResourceTypes
}),
_ => Results.Problem(
title: "Integration discovery failed",
detail: result.Message,
statusCode: StatusCodes.Status502BadGateway)
};
})
.RequireAuthorization(IntegrationPolicies.Operate)
.WithName("DiscoverIntegrationResources")
.WithDescription("Discover resources exposed by the integration provider.");
// Health check
group.MapGet("/{id:guid}/health", async (
[FromServices] IntegrationService service,
@@ -166,9 +194,11 @@ public static class IntegrationEndpoints
.WithDescription(_t("integrations.integration.impact_description"));
// Get supported providers
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
group.MapGet("/providers", (
[FromServices] IntegrationService service,
[FromQuery] bool includeTestOnly = false) =>
{
var result = service.GetSupportedProviders();
var result = service.GetSupportedProviders(includeTestOnly);
return Results.Ok(result);
})
.RequireAuthorization(IntegrationPolicies.Read)

View File

@@ -296,6 +296,109 @@ public sealed class IntegrationService
result.Duration);
}
public async Task<DiscoveryExecutionResult> DiscoverAsync(
Guid id,
DiscoverIntegrationRequest request,
string? tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
if (integration is null)
{
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.IntegrationNotFound,
null,
"Integration was not found in the current tenant scope.",
[]);
}
var discoveryPlugin = _pluginLoader.GetDiscoveryByProvider(integration.Provider);
if (discoveryPlugin is null)
{
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.Unsupported,
null,
$"Provider {integration.Provider} does not support resource discovery.",
[]);
}
var normalizedResourceType = IntegrationDiscoveryResourceTypes.Normalize(request.ResourceType);
if (string.IsNullOrWhiteSpace(normalizedResourceType))
{
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.Unsupported,
null,
"resourceType is required.",
discoveryPlugin.SupportedResourceTypes
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray());
}
var supportedResourceTypes = discoveryPlugin.SupportedResourceTypes
.Select(IntegrationDiscoveryResourceTypes.Normalize)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
if (!supportedResourceTypes.Contains(normalizedResourceType, StringComparer.Ordinal))
{
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.Unsupported,
null,
$"Provider {integration.Provider} does not support discovery for resource type '{normalizedResourceType}'.",
supportedResourceTypes);
}
try
{
var resolvedSecret = integration.AuthRefUri is not null
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
: null;
var config = BuildConfig(integration, resolvedSecret);
var resources = await discoveryPlugin.DiscoverAsync(
config,
normalizedResourceType,
request.Filter,
cancellationToken);
var orderedResources = resources
.OrderBy(resource => resource.ResourceType, StringComparer.Ordinal)
.ThenBy(resource => resource.Parent ?? string.Empty, StringComparer.Ordinal)
.ThenBy(resource => resource.Name, StringComparer.Ordinal)
.ThenBy(resource => resource.Id, StringComparer.Ordinal)
.ToArray();
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.Success,
new DiscoverIntegrationResponse(
integration.Id,
normalizedResourceType,
orderedResources,
_timeProvider.GetUtcNow()),
null,
supportedResourceTypes);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Resource discovery failed for integration {IntegrationId} ({Provider}) and resource type {ResourceType}",
integration.Id,
integration.Provider,
normalizedResourceType);
return new DiscoveryExecutionResult(
DiscoveryExecutionStatus.Failed,
null,
ex.Message,
supportedResourceTypes);
}
}
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
{
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
@@ -321,12 +424,34 @@ public sealed class IntegrationService
ImpactedWorkflows: impactedWorkflows);
}
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
public IReadOnlyList<ProviderInfo> GetSupportedProviders(bool includeTestOnly = false)
{
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
p.Name,
p.Type,
p.Provider)).ToList();
return _pluginLoader.Plugins
.GroupBy(plugin => plugin.Provider)
.Select(group => group.First())
.Where(plugin => includeTestOnly || !IsTestOnlyProvider(plugin.Provider))
.Select(plugin =>
{
var discoveryPlugin = plugin as IIntegrationDiscoveryPlugin;
var supportedResourceTypes = discoveryPlugin?.SupportedResourceTypes
.Select(IntegrationDiscoveryResourceTypes.Normalize)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray()
?? [];
return new ProviderInfo(
plugin.Name,
plugin.Type,
plugin.Provider,
IsTestOnlyProvider(plugin.Provider),
discoveryPlugin is not null,
supportedResourceTypes);
})
.OrderBy(info => info.Type)
.ThenBy(info => info.Provider)
.ToArray();
}
private async Task<Integration?> GetScopedIntegrationAsync(Guid id, string? tenantId, CancellationToken cancellationToken)
@@ -375,6 +500,11 @@ public sealed class IntegrationService
new ImpactedWorkflow("dependency-resolution", "security-risk", blockedByStatus, "Package advisory resolution and normalization.", "verify-repository-mirror"),
new ImpactedWorkflow("hot-lookup-projection", "security-risk", blockedByStatus, "Hot-lookup enrichment for findings explorer.", "resync-package-index"),
],
IntegrationType.ObjectStorage =>
[
new ImpactedWorkflow("airgap-bundle-staging", "platform-ops", blockedByStatus, "Air-gap bundle staging, export, and import checkpoints.", "revalidate-object-storage-endpoint"),
new ImpactedWorkflow("evidence-export", "evidence", blockedByStatus, "Evidence pack archival and download handoff storage.", "rerun-export-upload"),
],
IntegrationType.RuntimeHost =>
[
new ImpactedWorkflow("runtime-reachability", "security-risk", blockedByStatus, "Runtime witness ingestion for reachability confidence.", "restart-runtime-agent"),
@@ -437,27 +567,23 @@ public sealed class IntegrationService
integration.UpdatedBy,
integration.Tags);
}
private static bool IsTestOnlyProvider(IntegrationProvider provider)
{
return provider == IntegrationProvider.InMemory;
}
}
/// <summary>
/// Information about a supported provider.
/// </summary>
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);
public enum DiscoveryExecutionStatus
{
Success = 0,
IntegrationNotFound = 1,
Unsupported = 2,
Failed = 3
}
public sealed record IntegrationImpactResponse(
Guid IntegrationId,
string IntegrationName,
IntegrationType Type,
IntegrationProvider Provider,
IntegrationStatus Status,
string Severity,
int BlockingWorkflowCount,
int TotalWorkflowCount,
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
public sealed record ImpactedWorkflow(
string Workflow,
string Domain,
bool Blocking,
string Impact,
string RecommendedAction);
public sealed record DiscoveryExecutionResult(
DiscoveryExecutionStatus Status,
DiscoverIntegrationResponse? Response,
string? Message,
IReadOnlyList<string> SupportedResourceTypes);

View File

@@ -0,0 +1,117 @@
using StellaOps.Integrations.Core;
using System.Text.RegularExpressions;
namespace StellaOps.Integrations.Contracts;
/// <summary>
/// Request DTO for integration resource discovery.
/// </summary>
public sealed record DiscoverIntegrationRequest(
string ResourceType,
IReadOnlyDictionary<string, string>? Filter);
/// <summary>
/// Response DTO for integration resource discovery.
/// </summary>
public sealed record DiscoverIntegrationResponse(
Guid IntegrationId,
string ResourceType,
IReadOnlyList<DiscoveredIntegrationResource> Resources,
DateTimeOffset DiscoveredAt);
/// <summary>
/// A resource discovered through an integration connector.
/// </summary>
public sealed record DiscoveredIntegrationResource(
string ResourceType,
string Id,
string Name,
string? Parent,
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Optional plugin capability for resource discovery.
/// </summary>
public interface IIntegrationDiscoveryPlugin
{
/// <summary>
/// Resource types supported by the connector.
/// </summary>
IReadOnlyList<string> SupportedResourceTypes { get; }
/// <summary>
/// Discover resources exposed by the integration.
/// </summary>
Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Known discovery resource type ids used by integrations.
/// </summary>
public static class IntegrationDiscoveryResourceTypes
{
public const string Jobs = "jobs";
public const string Pipelines = "pipelines";
public const string Projects = "projects";
public const string Repositories = "repositories";
public const string Tags = "tags";
public static string Normalize(string resourceType)
{
return string.IsNullOrWhiteSpace(resourceType)
? string.Empty
: resourceType.Trim().ToLowerInvariant();
}
}
/// <summary>
/// Shared helpers for discovery filters.
/// </summary>
public static class IntegrationDiscoveryFilter
{
public static string? GetValue(IReadOnlyDictionary<string, string>? filter, string key)
{
if (filter is null || string.IsNullOrWhiteSpace(key))
{
return null;
}
foreach (var entry in filter)
{
if (string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase))
{
return string.IsNullOrWhiteSpace(entry.Value)
? null
: entry.Value.Trim();
}
}
return null;
}
public static bool MatchesNamePattern(string candidate, string? pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return true;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
var trimmedPattern = pattern.Trim();
if (!trimmedPattern.Contains('*', StringComparison.Ordinal))
{
return candidate.Contains(trimmedPattern, StringComparison.OrdinalIgnoreCase);
}
var regexPattern = "^" + Regex.Escape(trimmedPattern).Replace("\\*", ".*", StringComparison.Ordinal) + "$";
return Regex.IsMatch(candidate, regexPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
}

View File

@@ -95,3 +95,38 @@ public sealed record PagedIntegrationsResponse(
int Page,
int PageSize,
int TotalPages);
/// <summary>
/// Information about a supported integration provider.
/// </summary>
public sealed record ProviderInfo(
string Name,
IntegrationType Type,
IntegrationProvider Provider,
bool IsTestOnly,
bool SupportsDiscovery,
IReadOnlyList<string> SupportedResourceTypes);
/// <summary>
/// Response DTO for integration impact analysis.
/// </summary>
public sealed record IntegrationImpactResponse(
Guid IntegrationId,
string IntegrationName,
IntegrationType Type,
IntegrationProvider Provider,
IntegrationStatus Status,
string Severity,
int BlockingWorkflowCount,
int TotalWorkflowCount,
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
/// <summary>
/// Single impacted workflow entry within integration impact analysis.
/// </summary>
public sealed record ImpactedWorkflow(
string Workflow,
string Domain,
bool Blocking,
string Impact,
string RecommendedAction);

View File

@@ -30,7 +30,10 @@ public enum IntegrationType
Marketplace = 8,
/// <summary>Secrets/config management (Vault, Consul, etc.).</summary>
SecretsManager = 9
SecretsManager = 9,
/// <summary>Object storage used for air-gap bundles, exports, and mirrored artifacts.</summary>
ObjectStorage = 10
}
/// <summary>
@@ -74,6 +77,9 @@ public enum IntegrationProvider
CratesIo = 404,
GoProxy = 405,
// Object storage
S3Compatible = 450,
// Runtime hosts
EbpfAgent = 500,
EtwAgent = 501,

View File

@@ -1,5 +1,6 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.DockerRegistry;
/// 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
public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly TimeProvider _timeProvider;
@@ -31,6 +32,12 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Repositories,
IntegrationDiscoveryResourceTypes.Tags
];
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
@@ -130,6 +137,84 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
}
}
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
using var client = CreateHttpClient(config);
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
{
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(client, filter, cancellationToken),
IntegrationDiscoveryResourceTypes.Tags => await DiscoverTagsAsync(client, filter, cancellationToken),
_ => []
};
}
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var response = await client.GetAsync("/v2/_catalog", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var catalog = JsonSerializer.Deserialize<DockerCatalogResponse>(content, JsonOptions);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
return (catalog?.Repositories ?? [])
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository, namePattern))
.OrderBy(repository => repository, StringComparer.OrdinalIgnoreCase)
.Select(repository => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Repositories,
repository,
repository,
Parent: null,
Metadata: null))
.ToList();
}
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverTagsAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var repository = IntegrationDiscoveryFilter.GetValue(filter, "repository");
if (string.IsNullOrWhiteSpace(repository))
{
return [];
}
var repositoryPath = string.Join(
'/',
repository.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.EscapeDataString));
var response = await client.GetAsync($"/v2/{repositoryPath}/tags/list", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var tags = JsonSerializer.Deserialize<DockerTagsResponse>(content, JsonOptions);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
return (tags?.Tags ?? [])
.Where(tag => IntegrationDiscoveryFilter.MatchesNamePattern(tag, namePattern))
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(tag => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Tags,
$"{repository}:{tag}",
tag,
Parent: repository,
Metadata: new Dictionary<string, string>
{
["repository"] = repository
}))
.ToList();
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
@@ -140,10 +225,17 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Docker Registry uses Bearer token authentication if provided
if (!string.IsNullOrEmpty(config.ResolvedSecret))
if (!string.IsNullOrWhiteSpace(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
if (config.ResolvedSecret.Contains(':', StringComparison.Ordinal))
{
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(config.ResolvedSecret));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
else
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
}
}
return client;
@@ -160,4 +252,10 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
{
public List<string>? Repositories { get; set; }
}
private sealed class DockerTagsResponse
{
public string? Name { get; set; }
public List<string>? Tags { get; set; }
}
}

View File

@@ -0,0 +1,46 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.DockerRegistry;
/// <summary>
/// GitLab Container Registry connector backed by the OCI Distribution API.
/// Reuses the generic registry implementation but exposes the GitLab registry provider identity.
/// </summary>
public sealed class GitLabContainerRegistryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly DockerRegistryConnectorPlugin _inner;
public GitLabContainerRegistryConnectorPlugin()
: this(TimeProvider.System)
{
}
public GitLabContainerRegistryConnectorPlugin(TimeProvider? timeProvider = null)
{
_inner = new DockerRegistryConnectorPlugin(timeProvider);
}
public string Name => "gitlab-container-registry";
public IntegrationType Type => IntegrationType.Registry;
public IntegrationProvider Provider => IntegrationProvider.GitLabContainerRegistry;
public bool IsAvailable(IServiceProvider services) => _inner.IsAvailable(services);
public IReadOnlyList<string> SupportedResourceTypes => _inner.SupportedResourceTypes;
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
=> _inner.TestConnectionAsync(config, cancellationToken);
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
=> _inner.CheckHealthAsync(config, cancellationToken);
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
=> _inner.DiscoverAsync(config, resourceType, filter, cancellationToken);
}

View File

@@ -0,0 +1,46 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.GitLab;
/// <summary>
/// GitLab CI connector plugin backed by the GitLab v4 API.
/// Reuses the GitLab server connector implementation but advertises the CI/CD provider identity.
/// </summary>
public sealed class GitLabCiConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly GitLabConnectorPlugin _inner;
public GitLabCiConnectorPlugin()
: this(TimeProvider.System)
{
}
public GitLabCiConnectorPlugin(TimeProvider? timeProvider = null)
{
_inner = new GitLabConnectorPlugin(timeProvider);
}
public string Name => "gitlab-ci";
public IntegrationType Type => IntegrationType.CiCd;
public IntegrationProvider Provider => IntegrationProvider.GitLabCi;
public bool IsAvailable(IServiceProvider services) => _inner.IsAvailable(services);
public IReadOnlyList<string> SupportedResourceTypes => _inner.SupportedResourceTypes;
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
=> _inner.TestConnectionAsync(config, cancellationToken);
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
=> _inner.CheckHealthAsync(config, cancellationToken);
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
=> _inner.DiscoverAsync(config, resourceType, filter, cancellationToken);
}

View File

@@ -2,6 +2,7 @@ using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Integrations.Plugin.GitLab;
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.GitLab;
/// GitLab Server SCM connector plugin.
/// Supports GitLab v4 API (self-managed instances).
/// </summary>
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly TimeProvider _timeProvider;
@@ -31,6 +32,13 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Projects,
IntegrationDiscoveryResourceTypes.Repositories,
IntegrationDiscoveryResourceTypes.Pipelines
];
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
@@ -131,6 +139,124 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
}
}
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
using var client = CreateHttpClient(config);
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
{
IntegrationDiscoveryResourceTypes.Projects => await DiscoverProjectsAsync(client, filter, cancellationToken),
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(client, filter, cancellationToken),
IntegrationDiscoveryResourceTypes.Pipelines => await DiscoverPipelinesAsync(client, filter, cancellationToken),
_ => []
};
}
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverProjectsAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var projects = await SearchProjectsAsync(client, filter, cancellationToken);
return projects
.Where(project => !string.IsNullOrWhiteSpace(project.PathWithNamespace))
.OrderBy(project => project.PathWithNamespace, StringComparer.OrdinalIgnoreCase)
.Select(project => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Projects,
project.Id.ToString(),
project.PathWithNamespace!,
Parent: project.Namespace?.FullPath,
Metadata: new Dictionary<string, string>
{
["path"] = project.PathWithNamespace!,
["webUrl"] = project.WebUrl ?? string.Empty
}))
.ToList();
}
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var projects = await SearchProjectsAsync(client, filter, cancellationToken);
return projects
.Where(project => !string.IsNullOrWhiteSpace(project.PathWithNamespace))
.OrderBy(project => project.PathWithNamespace, StringComparer.OrdinalIgnoreCase)
.Select(project => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Repositories,
project.PathWithNamespace!,
project.PathWithNamespace!,
Parent: project.Namespace?.FullPath,
Metadata: new Dictionary<string, string>
{
["projectId"] = project.Id.ToString(),
["defaultBranch"] = project.DefaultBranch ?? string.Empty
}))
.ToList();
}
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverPipelinesAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var projectRef = IntegrationDiscoveryFilter.GetValue(filter, "projectId")
?? IntegrationDiscoveryFilter.GetValue(filter, "project");
if (string.IsNullOrWhiteSpace(projectRef))
{
return [];
}
var response = await client.GetAsync($"/api/v4/projects/{Uri.EscapeDataString(projectRef)}/pipelines?per_page=100", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var pipelines = JsonSerializer.Deserialize<List<GitLabPipelineDto>>(content, JsonOptions);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
return (pipelines ?? [])
.Where(pipeline => IntegrationDiscoveryFilter.MatchesNamePattern(pipeline.Ref ?? pipeline.Id.ToString(), namePattern))
.OrderBy(pipeline => pipeline.Ref ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(pipeline => pipeline.Id)
.Select(pipeline => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Pipelines,
pipeline.Id.ToString(),
pipeline.Ref ?? pipeline.Id.ToString(),
Parent: projectRef,
Metadata: new Dictionary<string, string>
{
["status"] = pipeline.Status ?? string.Empty,
["webUrl"] = pipeline.WebUrl ?? string.Empty
}))
.ToList();
}
private static async Task<List<GitLabProjectDto>> SearchProjectsAsync(
HttpClient client,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
var query = namePattern?.Replace("*", string.Empty, StringComparison.Ordinal).Trim() ?? string.Empty;
var response = await client.GetAsync($"/api/v4/projects?simple=true&per_page=100&search={Uri.EscapeDataString(query)}", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var projects = JsonSerializer.Deserialize<List<GitLabProjectDto>>(content, JsonOptions);
return (projects ?? [])
.Where(project => IntegrationDiscoveryFilter.MatchesNamePattern(project.PathWithNamespace ?? string.Empty, namePattern))
.ToList();
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
@@ -162,4 +288,36 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
public string? Version { get; set; }
public string? Revision { get; set; }
}
private sealed class GitLabProjectDto
{
public int Id { get; set; }
[JsonPropertyName("path_with_namespace")]
public string? PathWithNamespace { get; set; }
[JsonPropertyName("web_url")]
public string? WebUrl { get; set; }
[JsonPropertyName("default_branch")]
public string? DefaultBranch { get; set; }
public GitLabNamespaceDto? Namespace { get; set; }
}
private sealed class GitLabNamespaceDto
{
[JsonPropertyName("full_path")]
public string? FullPath { get; set; }
}
private sealed class GitLabPipelineDto
{
public int Id { get; set; }
public string? Status { get; set; }
public string? Ref { get; set; }
[JsonPropertyName("web_url")]
public string? WebUrl { get; set; }
}
}

View File

@@ -2,6 +2,7 @@ using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Integrations.Plugin.Gitea;
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.Gitea;
/// Gitea SCM connector plugin.
/// Supports Gitea v1.x API.
/// </summary>
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly TimeProvider _timeProvider;
@@ -31,6 +32,12 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Projects,
IntegrationDiscoveryResourceTypes.Repositories
];
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
@@ -129,6 +136,56 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
}
}
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
using var client = CreateHttpClient(config);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
var query = namePattern?.Replace("*", string.Empty, StringComparison.Ordinal).Trim() ?? string.Empty;
var response = await client.GetAsync($"/api/v1/repos/search?limit=100&q={Uri.EscapeDataString(query)}", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var searchResponse = JsonSerializer.Deserialize<GiteaRepositorySearchResponse>(content, JsonOptions);
var repositories = searchResponse?.Data ?? [];
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
{
IntegrationDiscoveryResourceTypes.Projects => repositories
.Select(repository => repository.Owner?.Login)
.Where(owner => !string.IsNullOrWhiteSpace(owner))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(owner => IntegrationDiscoveryFilter.MatchesNamePattern(owner!, namePattern))
.OrderBy(owner => owner, StringComparer.OrdinalIgnoreCase)
.Select(owner => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Projects,
owner!,
owner!,
Parent: null,
Metadata: null))
.ToList(),
IntegrationDiscoveryResourceTypes.Repositories => repositories
.Where(repository => !string.IsNullOrWhiteSpace(repository.FullName))
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository.FullName!, namePattern))
.OrderBy(repository => repository.FullName, StringComparer.OrdinalIgnoreCase)
.Select(repository => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Repositories,
repository.FullName!,
repository.FullName!,
Parent: repository.Owner?.Login,
Metadata: new Dictionary<string, string>
{
["defaultBranch"] = repository.DefaultBranch ?? string.Empty
}))
.ToList(),
_ => []
};
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
@@ -159,4 +216,25 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
{
public string? Version { get; set; }
}
private sealed class GiteaRepositorySearchResponse
{
public List<GiteaRepositoryDto>? Data { get; set; }
}
private sealed class GiteaRepositoryDto
{
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
[JsonPropertyName("default_branch")]
public string? DefaultBranch { get; set; }
public GiteaOwnerDto? Owner { get; set; }
}
private sealed class GiteaOwnerDto
{
public string? Login { get; set; }
}
}

View File

@@ -4,6 +4,7 @@ using StellaOps.Integrations.Core;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Integrations.Plugin.Harbor;
@@ -11,7 +12,7 @@ namespace StellaOps.Integrations.Plugin.Harbor;
/// Harbor container registry connector plugin.
/// Supports Harbor v2.x API.
/// </summary>
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly TimeProvider _timeProvider;
@@ -33,6 +34,12 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Repositories,
IntegrationDiscoveryResourceTypes.Tags
];
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
@@ -139,6 +146,20 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
}
}
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
{
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(config, filter, cancellationToken),
IntegrationDiscoveryResourceTypes.Tags => await DiscoverTagsAsync(config, filter, cancellationToken),
_ => []
};
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
@@ -231,6 +252,134 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
}
}
private async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
IntegrationConfig config,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var projects = await ListProjectsAsync(config, cancellationToken);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
var repositories = new List<DiscoveredIntegrationResource>();
foreach (var project in projects.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
{
var projectRepositories = await ListRepositoriesAsync(config, project, cancellationToken);
repositories.AddRange(projectRepositories
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository.Name, namePattern))
.Select(repository => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Repositories,
repository.Name,
repository.Name,
Parent: repository.Project,
Metadata: new Dictionary<string, string>
{
["project"] = repository.Project
})));
}
return repositories
.OrderBy(resource => resource.Parent ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(resource => resource.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverTagsAsync(
IntegrationConfig config,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken)
{
var repositoryPath = IntegrationDiscoveryFilter.GetValue(filter, "repository");
if (string.IsNullOrWhiteSpace(repositoryPath))
{
return [];
}
var split = repositoryPath.Split('/', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (split.Length != 2)
{
return [];
}
var artifacts = await ListArtifactsAsync(config, split[0], split[1], cancellationToken);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
return artifacts
.SelectMany(artifact => artifact.Tags.Select(tag => new { artifact.Digest, Tag = tag }))
.Where(item => IntegrationDiscoveryFilter.MatchesNamePattern(item.Tag, namePattern))
.OrderBy(item => item.Tag, StringComparer.OrdinalIgnoreCase)
.Select(item => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Tags,
$"{repositoryPath}:{item.Tag}",
item.Tag,
Parent: repositoryPath,
Metadata: new Dictionary<string, string>
{
["digest"] = item.Digest,
["project"] = split[0],
["repository"] = repositoryPath
}))
.ToList();
}
private async Task<List<string>> ListProjectsAsync(IntegrationConfig config, CancellationToken ct)
{
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/api/v2.0/projects?page=1&page_size=100", ct);
if (!response.IsSuccessStatusCode)
{
return [];
}
var content = await response.Content.ReadAsStringAsync(ct);
var projects = JsonSerializer.Deserialize<List<HarborProjectDto>>(content, JsonOptions);
return (projects ?? [])
.Select(project => project.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch
{
return [];
}
}
private async Task<List<RepositoryInfo>> ListRepositoriesAsync(IntegrationConfig config, string project, CancellationToken ct)
{
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync($"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories?page=1&page_size=100", ct);
if (!response.IsSuccessStatusCode)
{
return [];
}
var content = await response.Content.ReadAsStringAsync(ct);
var repositories = JsonSerializer.Deserialize<List<HarborRepositoryDto>>(content, JsonOptions);
return (repositories ?? [])
.Select(repository => new RepositoryInfo
{
Name = repository.Name ?? string.Empty,
Project = project,
Tags = []
})
.Where(repository => !string.IsNullOrWhiteSpace(repository.Name))
.ToList();
}
catch
{
return [];
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
@@ -257,14 +406,29 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
private sealed class HarborSearchRepository
{
[JsonPropertyName("repository_name")]
public string? RepositoryName { get; set; }
[JsonPropertyName("project_name")]
public string? ProjectName { get; set; }
}
private sealed class HarborProjectDto
{
public string? Name { get; set; }
}
private sealed class HarborRepositoryDto
{
public string? Name { get; set; }
}
private sealed class HarborArtifactDto
{
public string? Digest { get; set; }
public List<HarborTagDto>? Tags { get; set; }
[JsonPropertyName("push_time")]
public DateTimeOffset? PushTime { get; set; }
}

View File

@@ -10,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.Jenkins;
/// Jenkins CI/CD connector plugin.
/// Supports Jenkins REST API.
/// </summary>
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
private readonly TimeProvider _timeProvider;
@@ -32,6 +32,12 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Jobs,
IntegrationDiscoveryResourceTypes.Pipelines
];
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
@@ -141,6 +147,56 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
}
}
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
using var client = CreateHttpClient(config);
var response = await client.GetAsync("/api/json?tree=jobs[name,url,color]", cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var info = JsonSerializer.Deserialize<JenkinsJobListResponse>(content, JsonOptions);
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
{
IntegrationDiscoveryResourceTypes.Jobs => (info?.Jobs ?? [])
.Where(job => !string.IsNullOrWhiteSpace(job.Name))
.Where(job => IntegrationDiscoveryFilter.MatchesNamePattern(job.Name!, namePattern))
.OrderBy(job => job.Name, StringComparer.OrdinalIgnoreCase)
.Select(job => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Jobs,
job.Name!,
job.Name!,
Parent: null,
Metadata: new Dictionary<string, string>
{
["color"] = job.Color ?? string.Empty,
["url"] = job.Url ?? string.Empty
}))
.ToList(),
IntegrationDiscoveryResourceTypes.Pipelines => (info?.Jobs ?? [])
.Where(job => !string.IsNullOrWhiteSpace(job.Name))
.Where(job => IntegrationDiscoveryFilter.MatchesNamePattern(job.Name!, namePattern))
.OrderBy(job => job.Name, StringComparer.OrdinalIgnoreCase)
.Select(job => new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Pipelines,
job.Name!,
job.Name!,
Parent: null,
Metadata: new Dictionary<string, string>
{
["color"] = job.Color ?? string.Empty,
["url"] = job.Url ?? string.Empty
}))
.ToList(),
_ => []
};
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
@@ -174,4 +230,16 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
public string? NodeDescription { get; set; }
public int NumExecutors { get; set; }
}
private sealed class JenkinsJobListResponse
{
public List<JenkinsJobDto>? Jobs { get; set; }
}
private sealed class JenkinsJobDto
{
public string? Name { get; set; }
public string? Url { get; set; }
public string? Color { get; set; }
}
}

View File

@@ -0,0 +1,187 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.Plugin.DockerRegistry;
using Xunit;
namespace StellaOps.Integrations.Plugin.Tests;
public sealed class DockerRegistryConnectorPluginTests
{
[Fact]
public async Task DiscoverAsync_Repositories_UsesCatalogRouteAndAppliesNamePattern()
{
using var fixture = LoopbackHttpFixture.Start(path => path switch
{
"/v2/_catalog" => HttpResponse.Json("""{"repositories":["team/api","alpha/app","team/worker"]}"""),
_ => HttpResponse.Text("unexpected-path"),
});
var plugin = new DockerRegistryConnectorPlugin();
var resources = await plugin.DiscoverAsync(
CreateConfig(fixture.BaseUrl),
IntegrationDiscoveryResourceTypes.Repositories,
new Dictionary<string, string> { ["namePattern"] = "team/*" });
var requestedPath = await fixture.WaitForPathAsync();
Assert.Equal("/v2/_catalog", requestedPath);
Assert.Equal(["team/api", "team/worker"], resources.Select(resource => resource.Name).ToArray());
}
[Fact]
public async Task DiscoverAsync_Tags_UsesRepositoryRouteWithoutEncodingSlashes()
{
using var fixture = LoopbackHttpFixture.Start(path => path switch
{
"/v2/team/api/tags/list" => HttpResponse.Json("""{"name":"team/api","tags":["latest","1.0.0"]}"""),
_ => HttpResponse.Text("unexpected-path"),
});
var plugin = new DockerRegistryConnectorPlugin();
var resources = await plugin.DiscoverAsync(
CreateConfig(fixture.BaseUrl),
IntegrationDiscoveryResourceTypes.Tags,
new Dictionary<string, string>
{
["repository"] = "team/api"
});
var requestedPath = await fixture.WaitForPathAsync();
Assert.Equal("/v2/team/api/tags/list", requestedPath);
Assert.Equal(["1.0.0", "latest"], resources.Select(resource => resource.Name).ToArray());
}
[Fact]
public async Task TestConnectionAsync_WithUsernamePasswordSecret_UsesBasicAuthentication()
{
using var fixture = LoopbackHttpFixture.Start(path => path switch
{
"/v2/" => HttpResponse.Json("{}"),
_ => HttpResponse.Text("unexpected-path"),
});
var plugin = new DockerRegistryConnectorPlugin();
var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl, "gitlab-ci-token:secret"));
var request = await fixture.WaitForRequestAsync();
Assert.True(result.Success);
Assert.Equal("/v2/", request.Path);
Assert.Equal(
$"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("gitlab-ci-token:secret"))}",
request.Authorization);
}
private static IntegrationConfig CreateConfig(string endpoint, string? resolvedSecret = null)
{
return new IntegrationConfig(
IntegrationId: Guid.NewGuid(),
Type: IntegrationType.Registry,
Provider: IntegrationProvider.DockerHub,
Endpoint: endpoint,
ResolvedSecret: resolvedSecret,
OrganizationId: null,
ExtendedConfig: null);
}
private sealed class LoopbackHttpFixture : IDisposable
{
private readonly TcpListener _listener;
private readonly Task<HttpRequestData> _requestTask;
private LoopbackHttpFixture(Func<string, HttpResponse> responder)
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
_requestTask = HandleSingleRequestAsync(responder);
}
public string BaseUrl { get; }
public static LoopbackHttpFixture Start(Func<string, HttpResponse> responder) => new(responder);
public async Task<string> WaitForPathAsync() => (await _requestTask).Path;
public Task<HttpRequestData> WaitForRequestAsync() => _requestTask;
public void Dispose()
{
try
{
_listener.Stop();
}
catch
{
}
}
private async Task<HttpRequestData> HandleSingleRequestAsync(Func<string, HttpResponse> responder)
{
using var client = await _listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
using var reader = new StreamReader(
stream,
Encoding.ASCII,
detectEncodingFromByteOrderMarks: false,
bufferSize: 1024,
leaveOpen: true);
var requestLine = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(requestLine))
{
throw new InvalidOperationException("Did not receive an HTTP request line.");
}
var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestParts.Length < 2)
{
throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}");
}
string? authorization = null;
while (true)
{
var headerLine = await reader.ReadLineAsync();
if (string.IsNullOrEmpty(headerLine))
{
break;
}
if (headerLine.StartsWith("Authorization:", StringComparison.OrdinalIgnoreCase))
{
authorization = headerLine["Authorization:".Length..].Trim();
}
}
var requestPath = requestParts[1];
var response = responder(requestPath);
var payload = Encoding.UTF8.GetBytes(response.Body);
var responseText =
$"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" +
$"Content-Type: {response.ContentType}\r\n" +
$"Content-Length: {payload.Length}\r\n" +
"Connection: close\r\n" +
"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(responseText);
await stream.WriteAsync(headerBytes);
await stream.WriteAsync(payload);
await stream.FlushAsync();
return new HttpRequestData(requestPath, authorization);
}
}
private sealed record HttpRequestData(string Path, string? Authorization);
private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body)
{
public static HttpResponse Json(string body) => new(200, "OK", "application/json", body);
public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body);
}
}

View File

@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.WebService;
namespace StellaOps.Integrations.Plugin.Tests;
public sealed class S3CompatibleConnectorPluginTests
{
[Fact]
public async Task TestConnectionAsync_WithRootEndpoint_UsesMinioHealthProbe()
{
using var fixture = LoopbackHttpFixture.Start(path => path switch
{
"/minio/health/live" => HttpResponse.Text("OK"),
_ => HttpResponse.NotFound(),
});
var plugin = new S3CompatibleConnectorPlugin();
var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl));
var requestPath = await fixture.WaitForPathAsync();
Assert.True(result.Success);
Assert.Equal("/minio/health/live", requestPath);
}
[Fact]
public async Task CheckHealthAsync_WithUnhealthyProbe_ReturnsUnhealthy()
{
using var fixture = LoopbackHttpFixture.Start(path => path switch
{
"/custom/health" => HttpResponse.Unhealthy(),
_ => HttpResponse.NotFound(),
});
var plugin = new S3CompatibleConnectorPlugin();
var result = await plugin.CheckHealthAsync(CreateConfig($"{fixture.BaseUrl}/custom/health"));
var requestPath = await fixture.WaitForPathAsync();
Assert.Equal("/custom/health", requestPath);
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
private static IntegrationConfig CreateConfig(string endpoint)
{
return new IntegrationConfig(
IntegrationId: Guid.NewGuid(),
Type: IntegrationType.ObjectStorage,
Provider: IntegrationProvider.S3Compatible,
Endpoint: endpoint,
ResolvedSecret: null,
OrganizationId: null,
ExtendedConfig: null);
}
private sealed class LoopbackHttpFixture : IDisposable
{
private readonly TcpListener _listener;
private readonly Task<string> _requestTask;
private LoopbackHttpFixture(Func<string, HttpResponse> responder)
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
_requestTask = HandleSingleRequestAsync(responder);
}
public string BaseUrl { get; }
public static LoopbackHttpFixture Start(Func<string, HttpResponse> responder) => new(responder);
public Task<string> WaitForPathAsync() => _requestTask;
public void Dispose()
{
try
{
_listener.Stop();
}
catch
{
}
}
private async Task<string> HandleSingleRequestAsync(Func<string, HttpResponse> responder)
{
using var client = await _listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
using var reader = new StreamReader(
stream,
Encoding.ASCII,
detectEncodingFromByteOrderMarks: false,
bufferSize: 1024,
leaveOpen: true);
var requestLine = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(requestLine))
{
throw new InvalidOperationException("Did not receive an HTTP request line.");
}
var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestParts.Length < 2)
{
throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}");
}
while (true)
{
var headerLine = await reader.ReadLineAsync();
if (string.IsNullOrEmpty(headerLine))
{
break;
}
}
var requestPath = requestParts[1];
var response = responder(requestPath);
var payload = Encoding.UTF8.GetBytes(response.Body);
var responseText =
$"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" +
$"Content-Type: {response.ContentType}\r\n" +
$"Content-Length: {payload.Length}\r\n" +
"Connection: close\r\n" +
"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(responseText);
await stream.WriteAsync(headerBytes);
await stream.WriteAsync(payload);
await stream.FlushAsync();
return requestPath;
}
}
private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body)
{
public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body);
public static HttpResponse Unhealthy() => new(503, "Service Unavailable", "text/plain", "not-ready");
public static HttpResponse NotFound() => new(404, "Not Found", "text/plain", "missing");
}
}

View File

@@ -21,6 +21,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Integrations.WebService\StellaOps.Integrations.WebService.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.DockerRegistry\StellaOps.Integrations.Plugin.DockerRegistry.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />

View File

@@ -128,6 +128,88 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationI
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ProvidersEndpoint_HidesTestOnlyProviderByDefault()
{
var providers = await _client.GetFromJsonAsync<List<ProviderInfo>>(
"/api/v1/integrations/providers",
TestContext.Current.CancellationToken);
Assert.NotNull(providers);
Assert.DoesNotContain(providers!, provider => provider.Provider == IntegrationProvider.InMemory);
Assert.Contains(providers!, provider => provider.Provider == IntegrationProvider.Custom && provider.SupportsDiscovery);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ProvidersEndpoint_WithIncludeTestOnly_ReturnsInMemory()
{
var providers = await _client.GetFromJsonAsync<List<ProviderInfo>>(
"/api/v1/integrations/providers?includeTestOnly=true",
TestContext.Current.CancellationToken);
Assert.NotNull(providers);
Assert.Contains(providers!, provider => provider.Provider == IntegrationProvider.InMemory && provider.IsTestOnly);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task DiscoverEndpoint_WithUnsupportedProvider_ReturnsBadRequest()
{
var createRequest = new CreateIntegrationRequest(
Name: $"Test InMemory {Guid.NewGuid():N}",
Description: "Test only connector",
Type: IntegrationType.Registry,
Provider: IntegrationProvider.InMemory,
Endpoint: "http://inmemory.local",
AuthRefUri: null,
OrganizationId: null,
ExtendedConfig: null,
Tags: ["qa"]);
var createResponse = await _client.PostAsJsonAsync("/api/v1/integrations/", createRequest, TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
var discoverResponse = await _client.PostAsJsonAsync(
$"/api/v1/integrations/{created!.Id}/discover",
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, discoverResponse.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task DiscoverEndpoint_WithDiscoveryProvider_ReturnsOrderedResources()
{
var createRequest = new CreateIntegrationRequest(
Name: $"Custom SCM {Guid.NewGuid():N}",
Description: "Discovery connector",
Type: IntegrationType.Scm,
Provider: IntegrationProvider.Custom,
Endpoint: "https://custom.example.local",
AuthRefUri: null,
OrganizationId: null,
ExtendedConfig: null,
Tags: ["qa"]);
var createResponse = await _client.PostAsJsonAsync("/api/v1/integrations/", createRequest, TestContext.Current.CancellationToken);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
var discoverResponse = await _client.PostAsJsonAsync(
$"/api/v1/integrations/{created!.Id}/discover",
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
TestContext.Current.CancellationToken);
discoverResponse.EnsureSuccessStatusCode();
var discovered = await discoverResponse.Content.ReadFromJsonAsync<DiscoverIntegrationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(discovered);
Assert.Equal(["alpha/service", "zeta/service"], discovered!.Resources.Select(resource => resource.Name).ToArray());
}
}
public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFactory<Program>
@@ -150,6 +232,15 @@ public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFacto
services.RemoveAll<IIntegrationRepository>();
services.AddSingleton<IIntegrationRepository, InMemoryIntegrationRepository>();
services.RemoveAll<IntegrationPluginLoader>();
services.AddSingleton(sp =>
{
var loader = new IntegrationPluginLoader(sp.GetRequiredService<ILogger<IntegrationPluginLoader>>());
loader.Register(new FakeInMemoryConnectorPlugin());
loader.Register(new FakeDiscoveryConnectorPlugin());
return loader;
});
services.RemoveAll<IHostedService>();
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
@@ -394,3 +485,65 @@ internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
};
}
}
internal sealed class FakeInMemoryConnectorPlugin : IIntegrationConnectorPlugin
{
public string Name => "fake-inmemory";
public IntegrationType Type => IntegrationType.Registry;
public IntegrationProvider Provider => IntegrationProvider.InMemory;
public bool IsAvailable(IServiceProvider services) => true;
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
}
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
}
}
internal sealed class FakeDiscoveryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
public string Name => "fake-discovery";
public IntegrationType Type => IntegrationType.Scm;
public IntegrationProvider Provider => IntegrationProvider.Custom;
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Repositories
];
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
}
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
}
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
IReadOnlyList<DiscoveredIntegrationResource> resources =
[
new(resourceType, "zeta/service", "zeta/service", "zeta", null),
new(resourceType, "alpha/service", "alpha/service", "alpha", null)
];
return Task.FromResult(resources);
}
}

View File

@@ -295,6 +295,96 @@ public sealed class IntegrationServiceTests
_service.GetSupportedProviders().Should().BeEmpty();
}
[Trait("Category", "Unit")]
[Fact]
public void GetSupportedProviders_WithDiscoveryPlugin_HidesTestOnlyByDefault()
{
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
_pluginLoader.Register(new FakeTestOnlyConnectorPlugin());
var providers = _service.GetSupportedProviders();
providers.Should().ContainSingle(provider => provider.Provider == IntegrationProvider.Custom);
providers.Should().NotContain(provider => provider.Provider == IntegrationProvider.InMemory);
providers.Single().SupportsDiscovery.Should().BeTrue();
providers.Single().SupportedResourceTypes.Should().Equal(
IntegrationDiscoveryResourceTypes.Projects,
IntegrationDiscoveryResourceTypes.Repositories);
}
[Trait("Category", "Unit")]
[Fact]
public void GetSupportedProviders_WithIncludeTestOnly_IncludesInMemory()
{
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
_pluginLoader.Register(new FakeTestOnlyConnectorPlugin());
var providers = _service.GetSupportedProviders(includeTestOnly: true);
providers.Should().Contain(provider => provider.Provider == IntegrationProvider.InMemory && provider.IsTestOnly);
providers.Should().Contain(provider => provider.Provider == IntegrationProvider.Custom && provider.SupportsDiscovery);
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverAsync_WithUnsupportedResourceType_ReturnsUnsupportedAndSupportedTypes()
{
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom);
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
var result = await _service.DiscoverAsync(
integration.Id,
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Tags, null),
"tenant-1");
result.Status.Should().Be(DiscoveryExecutionStatus.Unsupported);
result.Response.Should().BeNull();
result.SupportedResourceTypes.Should().Equal(
IntegrationDiscoveryResourceTypes.Projects,
IntegrationDiscoveryResourceTypes.Repositories);
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverAsync_WithDiscoveryPlugin_ReturnsSortedResources()
{
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom);
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
var result = await _service.DiscoverAsync(
integration.Id,
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
"tenant-1");
result.Status.Should().Be(DiscoveryExecutionStatus.Success);
result.Response.Should().NotBeNull();
result.Response!.Resources.Select(resource => resource.Name).Should().Equal("alpha/service", "zeta/service");
}
[Trait("Category", "Unit")]
[Fact]
public async Task DiscoverAsync_WithTenantMismatch_ReturnsIntegrationNotFound()
{
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom, tenantId: "tenant-a");
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
var result = await _service.DiscoverAsync(
integration.Id,
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
"tenant-b");
result.Status.Should().Be(DiscoveryExecutionStatus.IntegrationNotFound);
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetImpactAsync_WithTenantMismatch_ReturnsNull()
@@ -356,4 +446,67 @@ public sealed class IntegrationServiceTests
UpdatedAt = now,
};
}
private sealed class FakeDiscoveryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
{
public string Name => "fake-discovery";
public IntegrationType Type => IntegrationType.Scm;
public IntegrationProvider Provider => IntegrationProvider.Custom;
public bool IsAvailable(IServiceProvider services) => true;
public IReadOnlyList<string> SupportedResourceTypes =>
[
IntegrationDiscoveryResourceTypes.Projects,
IntegrationDiscoveryResourceTypes.Repositories
];
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
}
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
}
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
IntegrationConfig config,
string resourceType,
IReadOnlyDictionary<string, string>? filter,
CancellationToken cancellationToken = default)
{
IReadOnlyList<DiscoveredIntegrationResource> resources =
[
new DiscoveredIntegrationResource(resourceType, "zeta/service", "zeta/service", "zeta", null),
new DiscoveredIntegrationResource(resourceType, "alpha/service", "alpha/service", "alpha", null)
];
return Task.FromResult(resources);
}
}
private sealed class FakeTestOnlyConnectorPlugin : IIntegrationConnectorPlugin
{
public string Name => "fake-inmemory";
public IntegrationType Type => IntegrationType.Registry;
public IntegrationProvider Provider => IntegrationProvider.InMemory;
public bool IsAvailable(IServiceProvider services) => true;
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
}
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
}
}
}