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

@@ -6516,7 +6516,7 @@ flowchart TB
// Sprint: SPRINT_20260118_011_CLI_settings_consolidation (CLI-S-004)
// stella config integrations - Integration settings (moved from stella integrations)
var integrationsCommand = NotifyCommandGroup.BuildIntegrationsCommand(services, verboseOption, cancellationToken);
var integrationsCommand = IntegrationsCommandGroup.BuildIntegrationsCommand(services, verboseOption, cancellationToken);
integrationsCommand.Description = "Integration configuration and testing.";
config.Add(integrationsCommand);

View File

@@ -0,0 +1,888 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Services;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Commands;
internal static class IntegrationsCommandGroup
{
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
internal static Command BuildIntegrationsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var integrations = new Command("integrations", "Manage integration catalog entries.");
integrations.Add(BuildListCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildProvidersCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildGetCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildCreateCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildUpdateCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildDeleteCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildTestCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildHealthCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildImpactCommand(services, verboseOption, cancellationToken));
integrations.Add(BuildDiscoverCommand(services, verboseOption, cancellationToken));
return integrations;
}
private static Command BuildListCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var typeOption = new Option<string?>("--type") { Description = "Filter by integration type." };
var providerOption = new Option<string?>("--provider") { Description = "Filter by provider." };
var statusOption = new Option<string?>("--status") { Description = "Filter by integration status." };
var searchOption = new Option<string?>("--search") { Description = "Search by integration name or description." };
var pageOption = new Option<int>("--page") { Description = "Page number (default: 1)." };
pageOption.SetDefaultValue(1);
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size (default: 20)." };
pageSizeOption.SetDefaultValue(20);
var sortByOption = new Option<string>("--sort-by") { Description = "Sort field (default: name)." };
sortByOption.SetDefaultValue("name");
var descendingOption = new Option<bool>("--descending") { Description = "Sort in descending order." };
var formatOption = BuildFormatOption();
var list = new Command("list", "List configured integrations.");
list.Add(typeOption);
list.Add(providerOption);
list.Add(statusOption);
list.Add(searchOption);
list.Add(pageOption);
list.Add(pageSizeOption);
list.Add(sortByOption);
list.Add(descendingOption);
list.Add(formatOption);
list.Add(verboseOption);
list.SetAction(async (parseResult, _) =>
{
try
{
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.ListIntegrationsAsync(
ParseOptionalEnum<IntegrationType>(parseResult.GetValue(typeOption)),
ParseOptionalEnum<IntegrationProvider>(parseResult.GetValue(providerOption)),
ParseOptionalEnum<IntegrationStatus>(parseResult.GetValue(statusOption)),
parseResult.GetValue(searchOption),
parseResult.GetValue(pageOption),
parseResult.GetValue(pageSizeOption),
parseResult.GetValue(sortByOption) ?? "name",
parseResult.GetValue(descendingOption),
cancellationToken).ConfigureAwait(false);
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteIntegrationList(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error listing integrations: {ex.Message}");
return 1;
}
});
return list;
}
private static Command BuildProvidersCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var includeTestOnlyOption = new Option<bool>("--include-test-only") { Description = "Include test-only providers." };
var formatOption = BuildFormatOption();
var providers = new Command("providers", "List supported integration providers.");
providers.Add(includeTestOnlyOption);
providers.Add(formatOption);
providers.Add(verboseOption);
providers.SetAction(async (parseResult, _) =>
{
try
{
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.ListIntegrationProvidersAsync(
parseResult.GetValue(includeTestOnlyOption),
cancellationToken).ConfigureAwait(false);
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteProviders(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error listing integration providers: {ex.Message}");
return 1;
}
});
return providers;
}
private static Command BuildGetCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var formatOption = BuildFormatOption();
var get = new Command("get", "Show integration details.");
get.Aliases.Add("show");
get.Add(idArgument);
get.Add(formatOption);
get.Add(verboseOption);
get.SetAction(async (parseResult, _) =>
{
try
{
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.GetIntegrationAsync(parseResult.GetValue(idArgument), cancellationToken).ConfigureAwait(false);
if (response is null)
{
Console.Error.WriteLine($"Integration '{parseResult.GetValue(idArgument)}' not found.");
return 1;
}
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteIntegrationDetails(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting integration: {ex.Message}");
return 1;
}
});
return get;
}
private static Command BuildCreateCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var nameOption = new Option<string>("--name") { Description = "Integration name." };
var descriptionOption = new Option<string?>("--description") { Description = "Optional description." };
var typeOption = new Option<string>("--type") { Description = "Integration type." };
var providerOption = new Option<string>("--provider") { Description = "Integration provider." };
var endpointOption = new Option<string>("--endpoint") { Description = "Provider endpoint URL." };
var authRefOption = new Option<string?>("--authref") { Description = "Auth reference URI." };
var organizationOption = new Option<string?>("--org") { Description = "Optional organization or namespace identifier." };
var tagOption = BuildMultiValueOption("--tag", "Tags to attach to the integration.");
var configOption = BuildMultiValueOption("--config", "Extended config entries in key=value form.");
var formatOption = BuildFormatOption();
var create = new Command("create", "Create an integration catalog entry.");
create.Add(nameOption);
create.Add(descriptionOption);
create.Add(typeOption);
create.Add(providerOption);
create.Add(endpointOption);
create.Add(authRefOption);
create.Add(organizationOption);
create.Add(tagOption);
create.Add(configOption);
create.Add(formatOption);
create.Add(verboseOption);
create.SetAction(async (parseResult, _) =>
{
try
{
var name = parseResult.GetValue(nameOption) ?? string.Empty;
var endpoint = parseResult.GetValue(endpointOption) ?? string.Empty;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(endpoint))
{
throw new InvalidOperationException("--name and --endpoint are required.");
}
var request = new CreateIntegrationRequest(
name,
parseResult.GetValue(descriptionOption),
ParseRequiredEnum<IntegrationType>(parseResult.GetValue(typeOption), "--type"),
ParseRequiredEnum<IntegrationProvider>(parseResult.GetValue(providerOption), "--provider"),
endpoint,
parseResult.GetValue(authRefOption),
parseResult.GetValue(organizationOption),
ParseObjectEntries(parseResult.GetValue(configOption), "--config"),
NormalizeValues(parseResult.GetValue(tagOption)));
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.CreateIntegrationAsync(request, cancellationToken).ConfigureAwait(false);
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
Console.WriteLine($"Integration '{response.Name}' created.");
Console.WriteLine();
WriteIntegrationDetails(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error creating integration: {ex.Message}");
return 1;
}
});
return create;
}
private static Command BuildUpdateCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var nameOption = new Option<string?>("--name") { Description = "Updated name." };
var descriptionOption = new Option<string?>("--description") { Description = "Updated description." };
var endpointOption = new Option<string?>("--endpoint") { Description = "Updated endpoint." };
var authRefOption = new Option<string?>("--authref") { Description = "Updated auth reference URI." };
var organizationOption = new Option<string?>("--org") { Description = "Updated organization or namespace identifier." };
var statusOption = new Option<string?>("--status") { Description = "Updated status." };
var tagOption = BuildMultiValueOption("--tag", "Replacement tags for the integration.");
var configOption = BuildMultiValueOption("--config", "Replacement extended config entries in key=value form.");
var formatOption = BuildFormatOption();
var update = new Command("update", "Update an integration catalog entry.");
update.Add(idArgument);
update.Add(nameOption);
update.Add(descriptionOption);
update.Add(endpointOption);
update.Add(authRefOption);
update.Add(organizationOption);
update.Add(statusOption);
update.Add(tagOption);
update.Add(configOption);
update.Add(formatOption);
update.Add(verboseOption);
update.SetAction(async (parseResult, _) =>
{
try
{
var request = new UpdateIntegrationRequest(
parseResult.GetValue(nameOption),
parseResult.GetValue(descriptionOption),
parseResult.GetValue(endpointOption),
parseResult.GetValue(authRefOption),
parseResult.GetValue(organizationOption),
HasValues(parseResult.GetValue(configOption)) ? ParseObjectEntries(parseResult.GetValue(configOption), "--config") : null,
HasValues(parseResult.GetValue(tagOption)) ? NormalizeValues(parseResult.GetValue(tagOption)) : null,
ParseOptionalEnum<IntegrationStatus>(parseResult.GetValue(statusOption)));
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.UpdateIntegrationAsync(
parseResult.GetValue(idArgument),
request,
cancellationToken).ConfigureAwait(false);
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
Console.WriteLine($"Integration '{response.Name}' updated.");
Console.WriteLine();
WriteIntegrationDetails(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error updating integration: {ex.Message}");
return 1;
}
});
return update;
}
private static Command BuildDeleteCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var delete = new Command("delete", "Delete an integration catalog entry.");
delete.Aliases.Add("remove");
delete.Add(idArgument);
delete.Add(verboseOption);
delete.SetAction(async (parseResult, _) =>
{
try
{
var id = parseResult.GetValue(idArgument);
var backend = services.GetRequiredService<IBackendOperationsClient>();
var deleted = await backend.DeleteIntegrationAsync(id, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
Console.Error.WriteLine($"Integration '{id}' not found.");
return 1;
}
Console.WriteLine($"Integration '{id}' deleted.");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error deleting integration: {ex.Message}");
return 1;
}
});
return delete;
}
private static Command BuildTestCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var formatOption = BuildFormatOption();
var test = new Command("test", "Test integration connectivity.");
test.Add(idArgument);
test.Add(formatOption);
test.Add(verboseOption);
test.SetAction(async (parseResult, _) =>
{
try
{
var id = parseResult.GetValue(idArgument);
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.TestIntegrationAsync(id, cancellationToken).ConfigureAwait(false);
if (response is null)
{
Console.Error.WriteLine($"Integration '{id}' not found.");
return 1;
}
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteTestResponse(response);
}
return response.Success ? 0 : 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error testing integration: {ex.Message}");
return 1;
}
});
return test;
}
private static Command BuildHealthCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var formatOption = BuildFormatOption();
var health = new Command("health", "Get current integration health.");
health.Add(idArgument);
health.Add(formatOption);
health.Add(verboseOption);
health.SetAction(async (parseResult, _) =>
{
try
{
var id = parseResult.GetValue(idArgument);
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.GetIntegrationHealthAsync(id, cancellationToken).ConfigureAwait(false);
if (response is null)
{
Console.Error.WriteLine($"Integration '{id}' not found.");
return 1;
}
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteHealthResponse(response);
}
return response.Status == HealthStatus.Unhealthy ? 1 : 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error retrieving integration health: {ex.Message}");
return 1;
}
});
return health;
}
private static Command BuildImpactCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var formatOption = BuildFormatOption();
var impact = new Command("impact", "Show workflow impact for an integration.");
impact.Add(idArgument);
impact.Add(formatOption);
impact.Add(verboseOption);
impact.SetAction(async (parseResult, _) =>
{
try
{
var id = parseResult.GetValue(idArgument);
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.GetIntegrationImpactAsync(id, cancellationToken).ConfigureAwait(false);
if (response is null)
{
Console.Error.WriteLine($"Integration '{id}' not found.");
return 1;
}
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteImpactResponse(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error retrieving integration impact: {ex.Message}");
return 1;
}
});
return impact;
}
private static Command BuildDiscoverCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
var resourceTypeOption = new Option<string>("--resource-type") { Description = "Resource type to discover." };
var filterOption = BuildMultiValueOption("--filter", "Discovery filter entries in key=value form.");
var formatOption = BuildFormatOption();
var discover = new Command("discover", "Discover provider resources for an integration.");
discover.Add(idArgument);
discover.Add(resourceTypeOption);
discover.Add(filterOption);
discover.Add(formatOption);
discover.Add(verboseOption);
discover.SetAction(async (parseResult, _) =>
{
try
{
var id = parseResult.GetValue(idArgument);
var resourceType = parseResult.GetValue(resourceTypeOption) ?? string.Empty;
if (string.IsNullOrWhiteSpace(resourceType))
{
throw new InvalidOperationException("--resource-type is required.");
}
var backend = services.GetRequiredService<IBackendOperationsClient>();
var response = await backend.DiscoverIntegrationResourcesAsync(
id,
new DiscoverIntegrationRequest(resourceType, ParseStringEntries(parseResult.GetValue(filterOption), "--filter")),
cancellationToken).ConfigureAwait(false);
if (response is null)
{
Console.Error.WriteLine($"Integration '{id}' not found.");
return 1;
}
var format = RequireFormat(parseResult.GetValue(formatOption));
if (IsJson(format))
{
WriteJson(response);
}
else
{
WriteDiscoveryResponse(response);
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error discovering integration resources: {ex.Message}");
return 1;
}
});
return discover;
}
private static Option<string> BuildFormatOption()
{
var option = new Option<string>("--format", "-f")
{
Description = "Output format: table (default) or json."
};
option.SetDefaultValue("table");
return option;
}
private static Option<string[]> BuildMultiValueOption(string name, string description)
{
var option = new Option<string[]>(name)
{
Description = description,
Arity = ArgumentArity.ZeroOrMore
};
option.AllowMultipleArgumentsPerToken = true;
option.SetDefaultValue([]);
return option;
}
private static string RequireFormat(string? format)
{
var resolved = string.IsNullOrWhiteSpace(format) ? "table" : format.Trim();
if (!resolved.Equals("table", StringComparison.OrdinalIgnoreCase) &&
!resolved.Equals("json", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported format '{resolved}'. Use 'table' or 'json'.");
}
return resolved;
}
private static bool IsJson(string format) => format.Equals("json", StringComparison.OrdinalIgnoreCase);
private static void WriteJson<T>(T value)
{
Console.WriteLine(JsonSerializer.Serialize(value, JsonOutputOptions));
}
private static void WriteIntegrationList(PagedIntegrationsResponse response)
{
if (response.Items.Count == 0)
{
Console.WriteLine("No integrations found.");
return;
}
Console.WriteLine("Integrations");
Console.WriteLine("============");
Console.WriteLine();
foreach (var item in response.Items)
{
Console.WriteLine($"{item.Id} {item.Name}");
Console.WriteLine($" type={item.Type} provider={item.Provider} status={item.Status} health={item.LastHealthStatus}");
Console.WriteLine($" endpoint={item.Endpoint}");
}
Console.WriteLine();
Console.WriteLine($"Page {response.Page}/{response.TotalPages} Total: {response.TotalCount}");
}
private static void WriteProviders(IReadOnlyList<ProviderInfo> providers)
{
if (providers.Count == 0)
{
Console.WriteLine("No providers available.");
return;
}
Console.WriteLine("Integration Providers");
Console.WriteLine("=====================");
Console.WriteLine();
foreach (var provider in providers)
{
Console.WriteLine($"{provider.Provider} ({provider.Name})");
Console.WriteLine($" type={provider.Type} testOnly={provider.IsTestOnly} discovery={provider.SupportsDiscovery}");
if (provider.SupportedResourceTypes.Count > 0)
{
Console.WriteLine($" resources={string.Join(", ", provider.SupportedResourceTypes)}");
}
}
}
private static void WriteIntegrationDetails(IntegrationResponse response)
{
Console.WriteLine($"Id: {response.Id}");
Console.WriteLine($"Name: {response.Name}");
Console.WriteLine($"Type: {response.Type}");
Console.WriteLine($"Provider: {response.Provider}");
Console.WriteLine($"Status: {response.Status}");
Console.WriteLine($"Endpoint: {response.Endpoint}");
Console.WriteLine($"Has Auth: {response.HasAuth}");
Console.WriteLine($"Organization: {response.OrganizationId ?? "(none)"}");
Console.WriteLine($"Health: {response.LastHealthStatus}");
Console.WriteLine($"Health Check: {response.LastHealthCheckAt?.ToString("u", CultureInfo.InvariantCulture) ?? "(never)"}");
Console.WriteLine($"Created: {response.CreatedAt:u}");
Console.WriteLine($"Updated: {response.UpdatedAt:u}");
Console.WriteLine($"Created By: {response.CreatedBy ?? "(unknown)"}");
Console.WriteLine($"Updated By: {response.UpdatedBy ?? "(unknown)"}");
Console.WriteLine($"Tags: {FormatTags(response.Tags)}");
}
private static void WriteTestResponse(TestConnectionResponse response)
{
Console.WriteLine($"Integration: {response.IntegrationId}");
Console.WriteLine($"Success: {response.Success}");
Console.WriteLine($"Message: {response.Message ?? "(none)"}");
Console.WriteLine($"Duration: {response.Duration}");
if (response.Details is { Count: > 0 })
{
Console.WriteLine("Details:");
foreach (var pair in response.Details.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($" {pair.Key}: {pair.Value}");
}
}
}
private static void WriteHealthResponse(HealthCheckResponse response)
{
Console.WriteLine($"Integration: {response.IntegrationId}");
Console.WriteLine($"Status: {response.Status}");
Console.WriteLine($"Message: {response.Message ?? "(none)"}");
Console.WriteLine($"Checked At: {response.CheckedAt:u}");
Console.WriteLine($"Duration: {response.Duration}");
if (response.Details is { Count: > 0 })
{
Console.WriteLine("Details:");
foreach (var pair in response.Details.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($" {pair.Key}: {pair.Value}");
}
}
}
private static void WriteImpactResponse(IntegrationImpactResponse response)
{
Console.WriteLine($"Integration: {response.IntegrationName} ({response.IntegrationId})");
Console.WriteLine($"Type: {response.Type}");
Console.WriteLine($"Provider: {response.Provider}");
Console.WriteLine($"Status: {response.Status}");
Console.WriteLine($"Severity: {response.Severity}");
Console.WriteLine($"Blocking: {response.BlockingWorkflowCount}/{response.TotalWorkflowCount}");
if (response.ImpactedWorkflows.Count == 0)
{
Console.WriteLine("Workflows: none");
return;
}
Console.WriteLine("Workflows:");
foreach (var workflow in response.ImpactedWorkflows)
{
Console.WriteLine($" {workflow.Workflow} [{workflow.Domain}] blocking={workflow.Blocking}");
Console.WriteLine($" impact={workflow.Impact}");
Console.WriteLine($" action={workflow.RecommendedAction}");
}
}
private static void WriteDiscoveryResponse(DiscoverIntegrationResponse response)
{
Console.WriteLine($"Integration: {response.IntegrationId}");
Console.WriteLine($"Resource Type: {response.ResourceType}");
Console.WriteLine($"Discovered At: {response.DiscoveredAt:u}");
Console.WriteLine($"Count: {response.Resources.Count}");
if (response.Resources.Count == 0)
{
return;
}
Console.WriteLine("Resources:");
foreach (var resource in response.Resources)
{
Console.WriteLine($" {resource.Name} (id={resource.Id})");
Console.WriteLine($" parent={resource.Parent ?? "(none)"} type={resource.ResourceType}");
if (resource.Metadata is { Count: > 0 })
{
Console.WriteLine($" metadata={string.Join(", ", resource.Metadata.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}={pair.Value}"))}");
}
}
}
private static IReadOnlyList<string>? NormalizeValues(string[]? values)
{
var normalized = (values ?? [])
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return normalized.Length == 0 ? null : normalized;
}
private static bool HasValues(string[]? values)
{
return values is { Length: > 0 } && values.Any(value => !string.IsNullOrWhiteSpace(value));
}
private static IReadOnlyDictionary<string, object>? ParseObjectEntries(string[]? entries, string optionName)
{
if (!HasValues(entries))
{
return null;
}
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries!)
{
var (key, value) = SplitKeyValue(entry, optionName);
result[key] = ParseScalarValue(value);
}
return result;
}
private static IReadOnlyDictionary<string, string>? ParseStringEntries(string[]? entries, string optionName)
{
if (!HasValues(entries))
{
return null;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries!)
{
var (key, value) = SplitKeyValue(entry, optionName);
result[key] = value;
}
return result;
}
private static (string Key, string Value) SplitKeyValue(string? entry, string optionName)
{
if (string.IsNullOrWhiteSpace(entry))
{
throw new InvalidOperationException($"{optionName} entries must use key=value syntax.");
}
var separatorIndex = entry.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex == entry.Length - 1)
{
throw new InvalidOperationException($"{optionName} entry '{entry}' must use key=value syntax.");
}
return (entry[..separatorIndex].Trim(), entry[(separatorIndex + 1)..].Trim());
}
private static object ParseScalarValue(string value)
{
if (bool.TryParse(value, out var boolValue))
{
return boolValue;
}
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
{
return intValue;
}
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleValue))
{
return doubleValue;
}
return value;
}
private static TEnum ParseRequiredEnum<TEnum>(string? value, string optionName)
where TEnum : struct, Enum
{
var parsed = ParseOptionalEnum<TEnum>(value);
if (!parsed.HasValue)
{
throw new InvalidOperationException($"{optionName} is required and must be a valid {typeof(TEnum).Name} value.");
}
return parsed.Value;
}
private static TEnum? ParseOptionalEnum<TEnum>(string? value)
where TEnum : struct, Enum
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericValue) &&
Enum.IsDefined(typeof(TEnum), numericValue))
{
return (TEnum)Enum.ToObject(typeof(TEnum), numericValue);
}
var normalized = NormalizeEnumToken(value);
foreach (var candidate in Enum.GetValues<TEnum>())
{
if (NormalizeEnumToken(candidate.ToString()).Equals(normalized, StringComparison.Ordinal))
{
return candidate;
}
}
throw new InvalidOperationException($"Invalid {typeof(TEnum).Name} value '{value}'.");
}
private static string NormalizeEnumToken(string value)
{
return new string(value.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
}
private static string FormatTags(IReadOnlyList<string> tags)
{
return tags.Count == 0 ? "(none)" : string.Join(", ", tags.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase));
}
}

View File

@@ -44,22 +44,6 @@ public static class NotifyCommandGroup
return notifyCommand;
}
/// <summary>
/// Build the 'integrations' command group.
/// </summary>
public static Command BuildIntegrationsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var integrationsCommand = new Command("integrations", "Integration management and testing");
integrationsCommand.Add(BuildIntegrationsListCommand(services, verboseOption, cancellationToken));
integrationsCommand.Add(BuildIntegrationsTestCommand(services, verboseOption, cancellationToken));
return integrationsCommand;
}
#region Channels Commands (NIN-001)
/// <summary>
@@ -470,6 +454,7 @@ public static class NotifyCommandGroup
#endregion
#if false
#region Integrations Commands (NIN-003)
private static Command BuildIntegrationsListCommand(
@@ -613,6 +598,7 @@ public static class NotifyCommandGroup
}
#endregion
#endif
#region Sample Data
@@ -640,18 +626,6 @@ public static class NotifyCommandGroup
];
}
private static List<Integration> GetIntegrations()
{
return
[
new Integration { Id = "github-scm", Type = "scm", Endpoint = "https://github.example.com", Status = "healthy" },
new Integration { Id = "gitlab-scm", Type = "scm", Endpoint = "https://gitlab.example.com", Status = "healthy" },
new Integration { Id = "harbor-registry", Type = "registry", Endpoint = "https://harbor.example.com", Status = "healthy" },
new Integration { Id = "vault-secrets", Type = "secrets", Endpoint = "https://vault.example.com", Status = "degraded" },
new Integration { Id = "jenkins-ci", Type = "ci", Endpoint = "https://jenkins.example.com", Status = "healthy" }
];
}
#endregion
#region DTOs
@@ -687,23 +661,5 @@ public static class NotifyCommandGroup
public Dictionary<string, string[]> Events { get; set; } = [];
}
private sealed class Integration
{
public string Id { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Endpoint { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}
private sealed class IntegrationTestResult
{
public string IntegrationId { get; set; } = string.Empty;
public bool Passed { get; set; }
public string Connectivity { get; set; } = string.Empty;
public string Authentication { get; set; } = string.Empty;
public int LatencyMs { get; set; }
public string? Error { get; set; }
}
#endregion
}

View File

@@ -549,19 +549,19 @@ public static class TopologyCommandGroup
var scopeTypeOption = new Option<string>("--scope-type", ["-s"])
{
Description = "Scope type: environment, target",
IsRequired = true
Required = true
};
var scopeIdOption = new Option<string>("--scope-id", ["-i"])
{
Description = "Scope entity ID",
IsRequired = true
Required = true
};
var integrationOption = new Option<string>("--integration", ["-g"])
{
Description = "Integration name to bind",
IsRequired = true
Required = true
};
var formatOption = new Option<string>("--format", ["-f"])

View File

@@ -8,6 +8,8 @@ using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Services.Models.Transport;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Digests;
using System;
@@ -5658,7 +5660,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return true;
}
public async Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
public async Task<StellaOps.Cli.Services.Models.TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
{
if (request is null)
{
@@ -5679,7 +5681,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<TestConnectionResult>(json, SerializerOptions)
return JsonSerializer.Deserialize<StellaOps.Cli.Services.Models.TestConnectionResult>(json, SerializerOptions)
?? throw new InvalidOperationException("Test connection response was empty.");
}
@@ -5716,4 +5718,288 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return true;
}
public async Task<PagedIntegrationsResponse> ListIntegrationsAsync(
IntegrationType? type,
IntegrationProvider? provider,
IntegrationStatus? status,
string? search,
int page,
int pageSize,
string sortBy,
bool sortDescending,
CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = new List<string>
{
$"page={page}",
$"pageSize={pageSize}",
$"sortBy={Uri.EscapeDataString(sortBy)}",
$"sortDescending={sortDescending.ToString().ToLowerInvariant()}"
};
if (type.HasValue)
{
query.Add($"type={(int)type.Value}");
}
if (provider.HasValue)
{
query.Add($"provider={(int)provider.Value}");
}
if (status.HasValue)
{
query.Add($"status={(int)status.Value}");
}
if (!string.IsNullOrWhiteSpace(search))
{
query.Add($"search={Uri.EscapeDataString(search)}");
}
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/?{string.Join("&", query)}");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to list integrations: {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<PagedIntegrationsResponse>(json, SerializerOptions)
?? throw new InvalidOperationException("List integrations response was empty.");
}
public async Task<IReadOnlyList<ProviderInfo>> ListIntegrationProvidersAsync(bool includeTestOnly, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var suffix = includeTestOnly ? "?includeTestOnly=true" : string.Empty;
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/providers{suffix}");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to list integration providers: {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<List<ProviderInfo>>(json, SerializerOptions)
?? new List<ProviderInfo>(0);
}
public async Task<IntegrationResponse?> GetIntegrationAsync(Guid id, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to get integration '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions);
}
public async Task<IntegrationResponse> CreateIntegrationAsync(CreateIntegrationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Post, "api/v1/integrations/");
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to create integration: {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions)
?? throw new InvalidOperationException("Create integration response was empty.");
}
public async Task<IntegrationResponse> UpdateIntegrationAsync(Guid id, UpdateIntegrationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Put, $"api/v1/integrations/{id}");
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to update integration '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions)
?? throw new InvalidOperationException("Update integration response was empty.");
}
public async Task<bool> DeleteIntegrationAsync(Guid id, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Delete, $"api/v1/integrations/{id}");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to delete integration '{id}': {failure}");
}
return true;
}
public async Task<TestConnectionResponse?> TestIntegrationAsync(Guid id, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/integrations/{id}/test");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to test integration '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<TestConnectionResponse>(json, SerializerOptions);
}
public async Task<HealthCheckResponse?> GetIntegrationHealthAsync(Guid id, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}/health");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to get integration health '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<HealthCheckResponse>(json, SerializerOptions);
}
public async Task<IntegrationImpactResponse?> GetIntegrationImpactAsync(Guid id, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}/impact");
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to get integration impact '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<IntegrationImpactResponse>(json, SerializerOptions);
}
public async Task<DiscoverIntegrationResponse?> DiscoverIntegrationResourcesAsync(
Guid id,
DiscoverIntegrationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/integrations/{id}/discover");
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
try
{
var error = JsonSerializer.Deserialize<IntegrationDiscoveryErrorResponse>(body, SerializerOptions);
var supported = error?.SupportedResourceTypes is { Count: > 0 }
? $" Supported resource types: {string.Join(", ", error.SupportedResourceTypes)}."
: string.Empty;
throw new InvalidOperationException($"{error?.Error ?? "Integration discovery failed."}{supported}");
}
catch (JsonException)
{
throw new InvalidOperationException(body);
}
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to discover integration resources for '{id}': {failure}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<DiscoverIntegrationResponse>(json, SerializerOptions);
}
private sealed class IntegrationDiscoveryErrorResponse
{
public string? Error { get; init; }
public List<string> SupportedResourceTypes { get; init; } = [];
}
}

View File

@@ -4,6 +4,8 @@ using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
@@ -172,7 +174,28 @@ internal interface IBackendOperationsClient
Task<IdentityProviderDto> CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken);
Task<IdentityProviderDto> UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken);
Task<bool> DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken);
Task<StellaOps.Cli.Services.Models.TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken);
Task<bool> EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
Task<bool> DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
Task<PagedIntegrationsResponse> ListIntegrationsAsync(
IntegrationType? type,
IntegrationProvider? provider,
IntegrationStatus? status,
string? search,
int page,
int pageSize,
string sortBy,
bool sortDescending,
CancellationToken cancellationToken);
Task<IReadOnlyList<ProviderInfo>> ListIntegrationProvidersAsync(bool includeTestOnly, CancellationToken cancellationToken);
Task<IntegrationResponse?> GetIntegrationAsync(Guid id, CancellationToken cancellationToken);
Task<IntegrationResponse> CreateIntegrationAsync(CreateIntegrationRequest request, CancellationToken cancellationToken);
Task<IntegrationResponse> UpdateIntegrationAsync(Guid id, UpdateIntegrationRequest request, CancellationToken cancellationToken);
Task<bool> DeleteIntegrationAsync(Guid id, CancellationToken cancellationToken);
Task<TestConnectionResponse?> TestIntegrationAsync(Guid id, CancellationToken cancellationToken);
Task<HealthCheckResponse?> GetIntegrationHealthAsync(Guid id, CancellationToken cancellationToken);
Task<IntegrationImpactResponse?> GetIntegrationImpactAsync(Guid id, CancellationToken cancellationToken);
Task<DiscoverIntegrationResponse?> DiscoverIntegrationResourcesAsync(Guid id, DiscoverIntegrationRequest request, CancellationToken cancellationToken);
}

View File

@@ -89,6 +89,8 @@
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Interop/StellaOps.Policy.Interop.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
<ProjectReference Include="../../Integrations/__Libraries/StellaOps.Integrations.Core/StellaOps.Integrations.Core.csproj" />
<ProjectReference Include="../../Integrations/__Libraries/StellaOps.Integrations.Contracts/StellaOps.Integrations.Contracts.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj" />
@@ -181,4 +183,3 @@
</PropertyGroup>
</Project>

View File

@@ -67,6 +67,76 @@
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations providers",
"new": "config integrations providers",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations get",
"new": "config integrations get",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations show",
"new": "config integrations get",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations create",
"new": "config integrations create",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations update",
"new": "config integrations update",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations delete",
"new": "config integrations delete",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations remove",
"new": "config integrations delete",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations health",
"new": "config integrations health",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations impact",
"new": "config integrations impact",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "integrations discover",
"new": "config integrations discover",
"type": "deprecated",
"removeIn": "3.0",
"reason": "Integration configuration consolidated under config"
},
{
"old": "registry list",
"new": "config registry list",

View File

@@ -0,0 +1,249 @@
using System.CommandLine;
using System.Globalization;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Services;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", TestCategories.Unit)]
public sealed class IntegrationsCommandGroupTests
{
[Fact]
public async Task ListCommand_JsonOutput_CallsBackendAndEmitsEnumsAsStrings()
{
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
backend
.Setup(client => client.ListIntegrationsAsync(
IntegrationType.Registry,
IntegrationProvider.Harbor,
IntegrationStatus.Active,
"prod",
1,
20,
"name",
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedIntegrationsResponse(
[
new IntegrationResponse(
Guid.Parse("00000000-0000-0000-0000-000000000101"),
"Prod Harbor",
"registry",
IntegrationType.Registry,
IntegrationProvider.Harbor,
IntegrationStatus.Active,
"https://harbor.example.com",
true,
"platform",
HealthStatus.Healthy,
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
DateTimeOffset.Parse("2026-04-04T07:00:00Z", CultureInfo.InvariantCulture),
DateTimeOffset.Parse("2026-04-04T07:30:00Z", CultureInfo.InvariantCulture),
"ops",
"ops",
["prod"])
],
1,
1,
20,
1));
using var services = new ServiceCollection()
.AddSingleton(backend.Object)
.BuildServiceProvider();
var root = new RootCommand();
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
var invocation = await InvokeWithCapturedConsoleAsync(
root,
"integrations list --type registry --provider harbor --status active --search prod --format json");
Assert.Equal(0, invocation.ExitCode);
using var document = JsonDocument.Parse(invocation.StdOut);
var item = document.RootElement.GetProperty("items")[0];
Assert.Equal("Prod Harbor", item.GetProperty("name").GetString());
Assert.Equal("Registry", item.GetProperty("type").GetString());
Assert.Equal("Harbor", item.GetProperty("provider").GetString());
Assert.Equal("Active", item.GetProperty("status").GetString());
backend.VerifyAll();
}
[Fact]
public async Task CreateCommand_BuildsLiveCreateRequest()
{
CreateIntegrationRequest? captured = null;
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
backend
.Setup(client => client.CreateIntegrationAsync(It.IsAny<CreateIntegrationRequest>(), It.IsAny<CancellationToken>()))
.Callback<CreateIntegrationRequest, CancellationToken>((request, _) => captured = request)
.ReturnsAsync(new IntegrationResponse(
Guid.Parse("00000000-0000-0000-0000-000000000102"),
"GitLab Prod",
"scm",
IntegrationType.Scm,
IntegrationProvider.GitLabServer,
IntegrationStatus.Pending,
"https://gitlab.example.com",
true,
"team-a",
HealthStatus.Unknown,
null,
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
"ops",
"ops",
["prod", "scm"]));
using var services = new ServiceCollection()
.AddSingleton(backend.Object)
.BuildServiceProvider();
var root = new RootCommand();
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
var invocation = await InvokeWithCapturedConsoleAsync(
root,
"integrations create --name \"GitLab Prod\" --type scm --provider gitlab-server --endpoint https://gitlab.example.com --authref authref://vault/gitlab#token --org team-a --tag prod scm --config project=platform enabled=true");
Assert.Equal(0, invocation.ExitCode);
Assert.NotNull(captured);
Assert.Equal("GitLab Prod", captured!.Name);
Assert.Equal(IntegrationType.Scm, captured.Type);
Assert.Equal(IntegrationProvider.GitLabServer, captured.Provider);
Assert.Equal("https://gitlab.example.com", captured.Endpoint);
Assert.Equal("authref://vault/gitlab#token", captured.AuthRefUri);
Assert.Equal("team-a", captured.OrganizationId);
Assert.Equal(["prod", "scm"], captured.Tags);
Assert.Equal("platform", Assert.IsType<string>(captured.ExtendedConfig!["project"]));
Assert.True(Assert.IsType<bool>(captured.ExtendedConfig["enabled"]));
backend.VerifyAll();
}
[Fact]
public async Task ProvidersCommand_JsonOutput_IncludesDiscoveryMetadata()
{
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
backend
.Setup(client => client.ListIntegrationProvidersAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync([
new ProviderInfo(
"harbor",
IntegrationType.Registry,
IntegrationProvider.Harbor,
false,
true,
[IntegrationDiscoveryResourceTypes.Repositories, IntegrationDiscoveryResourceTypes.Tags]),
new ProviderInfo(
"inmemory",
IntegrationType.Registry,
IntegrationProvider.InMemory,
true,
false,
[])
]);
using var services = new ServiceCollection()
.AddSingleton(backend.Object)
.BuildServiceProvider();
var root = new RootCommand();
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
var invocation = await InvokeWithCapturedConsoleAsync(root, "integrations providers --include-test-only --format json");
Assert.Equal(0, invocation.ExitCode);
using var document = JsonDocument.Parse(invocation.StdOut);
Assert.Equal(2, document.RootElement.GetArrayLength());
Assert.True(document.RootElement[0].GetProperty("supportsDiscovery").GetBoolean());
Assert.True(document.RootElement[1].GetProperty("isTestOnly").GetBoolean());
backend.VerifyAll();
}
[Fact]
public async Task DiscoverCommand_BuildsFilterDictionary()
{
DiscoverIntegrationRequest? captured = null;
var integrationId = Guid.Parse("00000000-0000-0000-0000-000000000103");
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
backend
.Setup(client => client.DiscoverIntegrationResourcesAsync(
integrationId,
It.IsAny<DiscoverIntegrationRequest>(),
It.IsAny<CancellationToken>()))
.Callback<Guid, DiscoverIntegrationRequest, CancellationToken>((_, request, _) => captured = request)
.ReturnsAsync(new DiscoverIntegrationResponse(
integrationId,
IntegrationDiscoveryResourceTypes.Tags,
[
new DiscoveredIntegrationResource(
IntegrationDiscoveryResourceTypes.Tags,
"team/api:latest",
"latest",
"team/api",
new Dictionary<string, string> { ["repository"] = "team/api" })
],
DateTimeOffset.Parse("2026-04-04T08:15:00Z", CultureInfo.InvariantCulture)));
using var services = new ServiceCollection()
.AddSingleton(backend.Object)
.BuildServiceProvider();
var root = new RootCommand();
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
var invocation = await InvokeWithCapturedConsoleAsync(
root,
$"integrations discover {integrationId} --resource-type tags --filter repository=team/api namePattern=latest --format json");
Assert.Equal(0, invocation.ExitCode);
Assert.NotNull(captured);
Assert.Equal(IntegrationDiscoveryResourceTypes.Tags, captured!.ResourceType);
Assert.Equal("team/api", captured.Filter!["repository"]);
Assert.Equal("latest", captured.Filter["namePattern"]);
backend.VerifyAll();
}
private static async Task<CommandInvocationResult> InvokeWithCapturedConsoleAsync(RootCommand root, string commandLine)
{
var originalOut = Console.Out;
var originalError = Console.Error;
var originalExitCode = Environment.ExitCode;
Environment.ExitCode = 0;
var stdout = new StringWriter(CultureInfo.InvariantCulture);
var stderr = new StringWriter(CultureInfo.InvariantCulture);
try
{
Console.SetOut(stdout);
Console.SetError(stderr);
var exitCode = await root.Parse(commandLine).InvokeAsync();
var capturedExitCode = Environment.ExitCode != 0 ? Environment.ExitCode : exitCode;
return new CommandInvocationResult(capturedExitCode, stdout.ToString(), stderr.ToString());
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalError);
Environment.ExitCode = originalExitCode;
}
}
private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr);
}

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