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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user