From 1cff9ef9cc9f77e7524fe562cf610922a6559f8a Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 6 Apr 2026 08:52:24 +0300 Subject: [PATCH] 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) --- .../StellaOps.Cli/Commands/CommandFactory.cs | 2 +- .../Commands/IntegrationsCommandGroup.cs | 888 ++++++++++++++++++ .../Commands/NotifyCommandGroup.cs | 48 +- .../Commands/Topology/TopologyCommandGroup.cs | 6 +- .../Services/BackendOperationsClient.cs | 290 +++++- .../Services/IBackendOperationsClient.cs | 25 +- src/Cli/StellaOps.Cli/StellaOps.Cli.csproj | 3 +- src/Cli/StellaOps.Cli/cli-routes.json | 70 ++ .../Commands/IntegrationsCommandGroupTests.cs | 249 +++++ .../IntegrationEndpoints.cs | 34 +- .../IntegrationService.cs | 178 +++- .../IntegrationDiscovery.cs | 117 +++ .../IntegrationDtos.cs | 35 + .../IntegrationEnums.cs | 8 +- .../DockerRegistryConnectorPlugin.cs | 106 ++- .../GitLabContainerRegistryConnectorPlugin.cs | 46 + .../GitLabCiConnectorPlugin.cs | 46 + .../GitLabConnectorPlugin.cs | 160 +++- .../GiteaConnectorPlugin.cs | 80 +- .../HarborConnectorPlugin.cs | 166 +++- .../JenkinsConnectorPlugin.cs | 70 +- .../DockerRegistryConnectorPluginTests.cs | 187 ++++ .../S3CompatibleConnectorPluginTests.cs | 147 +++ ...StellaOps.Integrations.Plugin.Tests.csproj | 2 + .../IntegrationImpactEndpointsTests.cs | 153 +++ .../IntegrationServiceTests.cs | 153 +++ 26 files changed, 3178 insertions(+), 91 deletions(-) create mode 100644 src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs create mode 100644 src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs create mode 100644 src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDiscovery.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/GitLabContainerRegistryConnectorPlugin.cs create mode 100644 src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabCiConnectorPlugin.cs create mode 100644 src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/DockerRegistryConnectorPluginTests.cs create mode 100644 src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/S3CompatibleConnectorPluginTests.cs diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index 2322607e0..ed0c2273f 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -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); diff --git a/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs new file mode 100644 index 000000000..34666714d --- /dev/null +++ b/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs @@ -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 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 verboseOption, CancellationToken cancellationToken) + { + var typeOption = new Option("--type") { Description = "Filter by integration type." }; + var providerOption = new Option("--provider") { Description = "Filter by provider." }; + var statusOption = new Option("--status") { Description = "Filter by integration status." }; + var searchOption = new Option("--search") { Description = "Search by integration name or description." }; + var pageOption = new Option("--page") { Description = "Page number (default: 1)." }; + pageOption.SetDefaultValue(1); + var pageSizeOption = new Option("--page-size") { Description = "Page size (default: 20)." }; + pageSizeOption.SetDefaultValue(20); + var sortByOption = new Option("--sort-by") { Description = "Sort field (default: name)." }; + sortByOption.SetDefaultValue("name"); + var descendingOption = new Option("--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(); + var response = await backend.ListIntegrationsAsync( + ParseOptionalEnum(parseResult.GetValue(typeOption)), + ParseOptionalEnum(parseResult.GetValue(providerOption)), + ParseOptionalEnum(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 verboseOption, CancellationToken cancellationToken) + { + var includeTestOnlyOption = new Option("--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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var nameOption = new Option("--name") { Description = "Integration name." }; + var descriptionOption = new Option("--description") { Description = "Optional description." }; + var typeOption = new Option("--type") { Description = "Integration type." }; + var providerOption = new Option("--provider") { Description = "Integration provider." }; + var endpointOption = new Option("--endpoint") { Description = "Provider endpoint URL." }; + var authRefOption = new Option("--authref") { Description = "Auth reference URI." }; + var organizationOption = new Option("--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(parseResult.GetValue(typeOption), "--type"), + ParseRequiredEnum(parseResult.GetValue(providerOption), "--provider"), + endpoint, + parseResult.GetValue(authRefOption), + parseResult.GetValue(organizationOption), + ParseObjectEntries(parseResult.GetValue(configOption), "--config"), + NormalizeValues(parseResult.GetValue(tagOption))); + + var backend = services.GetRequiredService(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("integration-id") { Description = "Integration ID." }; + var nameOption = new Option("--name") { Description = "Updated name." }; + var descriptionOption = new Option("--description") { Description = "Updated description." }; + var endpointOption = new Option("--endpoint") { Description = "Updated endpoint." }; + var authRefOption = new Option("--authref") { Description = "Updated auth reference URI." }; + var organizationOption = new Option("--org") { Description = "Updated organization or namespace identifier." }; + var statusOption = new Option("--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(parseResult.GetValue(statusOption))); + + var backend = services.GetRequiredService(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("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(); + 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 verboseOption, CancellationToken cancellationToken) + { + var idArgument = new Argument("integration-id") { Description = "Integration ID." }; + var resourceTypeOption = new Option("--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(); + 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 BuildFormatOption() + { + var option = new Option("--format", "-f") + { + Description = "Output format: table (default) or json." + }; + option.SetDefaultValue("table"); + return option; + } + + private static Option BuildMultiValueOption(string name, string description) + { + var option = new Option(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 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 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? 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? ParseObjectEntries(string[]? entries, string optionName) + { + if (!HasValues(entries)) + { + return null; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries!) + { + var (key, value) = SplitKeyValue(entry, optionName); + result[key] = ParseScalarValue(value); + } + + return result; + } + + private static IReadOnlyDictionary? ParseStringEntries(string[]? entries, string optionName) + { + if (!HasValues(entries)) + { + return null; + } + + var result = new Dictionary(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(string? value, string optionName) + where TEnum : struct, Enum + { + var parsed = ParseOptionalEnum(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(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()) + { + 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 tags) + { + return tags.Count == 0 ? "(none)" : string.Join(", ", tags.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/src/Cli/StellaOps.Cli/Commands/NotifyCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/NotifyCommandGroup.cs index 6e4676a4b..5a2b6347a 100644 --- a/src/Cli/StellaOps.Cli/Commands/NotifyCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/NotifyCommandGroup.cs @@ -44,22 +44,6 @@ public static class NotifyCommandGroup return notifyCommand; } - /// - /// Build the 'integrations' command group. - /// - public static Command BuildIntegrationsCommand( - IServiceProvider services, - Option 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) /// @@ -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 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 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 } diff --git a/src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs index 6b9990489..a7aa3a8f4 100644 --- a/src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/Topology/TopologyCommandGroup.cs @@ -549,19 +549,19 @@ public static class TopologyCommandGroup var scopeTypeOption = new Option("--scope-type", ["-s"]) { Description = "Scope type: environment, target", - IsRequired = true + Required = true }; var scopeIdOption = new Option("--scope-id", ["-i"]) { Description = "Scope entity ID", - IsRequired = true + Required = true }; var integrationOption = new Option("--integration", ["-g"]) { Description = "Integration name to bind", - IsRequired = true + Required = true }; var formatOption = new Option("--format", ["-f"]) diff --git a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs index 1df8c1c0f..2ccc09c3e 100644 --- a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs @@ -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 TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken) + public async Task 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(json, SerializerOptions) + return JsonSerializer.Deserialize(json, SerializerOptions) ?? throw new InvalidOperationException("Test connection response was empty."); } @@ -5716,4 +5718,288 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient return true; } + + public async Task ListIntegrationsAsync( + IntegrationType? type, + IntegrationProvider? provider, + IntegrationStatus? status, + string? search, + int page, + int pageSize, + string sortBy, + bool sortDescending, + CancellationToken cancellationToken) + { + EnsureBackendConfigured(); + + var query = new List + { + $"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(json, SerializerOptions) + ?? throw new InvalidOperationException("List integrations response was empty."); + } + + public async Task> 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>(json, SerializerOptions) + ?? new List(0); + } + + public async Task 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(json, SerializerOptions); + } + + public async Task 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(json, SerializerOptions) + ?? throw new InvalidOperationException("Create integration response was empty."); + } + + public async Task 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(json, SerializerOptions) + ?? throw new InvalidOperationException("Update integration response was empty."); + } + + public async Task 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 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(json, SerializerOptions); + } + + public async Task 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(json, SerializerOptions); + } + + public async Task 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(json, SerializerOptions); + } + + public async Task 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(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(json, SerializerOptions); + } + + private sealed class IntegrationDiscoveryErrorResponse + { + public string? Error { get; init; } + + public List SupportedResourceTypes { get; init; } = []; + } } diff --git a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs index b98f8e3f2..4787a71c2 100644 --- a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs @@ -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 CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken); Task UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken); Task DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken); - Task TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken); + Task TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken); Task EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken); Task DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken); + + Task ListIntegrationsAsync( + IntegrationType? type, + IntegrationProvider? provider, + IntegrationStatus? status, + string? search, + int page, + int pageSize, + string sortBy, + bool sortDescending, + CancellationToken cancellationToken); + + Task> ListIntegrationProvidersAsync(bool includeTestOnly, CancellationToken cancellationToken); + Task GetIntegrationAsync(Guid id, CancellationToken cancellationToken); + Task CreateIntegrationAsync(CreateIntegrationRequest request, CancellationToken cancellationToken); + Task UpdateIntegrationAsync(Guid id, UpdateIntegrationRequest request, CancellationToken cancellationToken); + Task DeleteIntegrationAsync(Guid id, CancellationToken cancellationToken); + Task TestIntegrationAsync(Guid id, CancellationToken cancellationToken); + Task GetIntegrationHealthAsync(Guid id, CancellationToken cancellationToken); + Task GetIntegrationImpactAsync(Guid id, CancellationToken cancellationToken); + Task DiscoverIntegrationResourcesAsync(Guid id, DiscoverIntegrationRequest request, CancellationToken cancellationToken); } diff --git a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj index c7be7402b..edca4b0c1 100644 --- a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj +++ b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj @@ -89,6 +89,8 @@ + + @@ -181,4 +183,3 @@ - diff --git a/src/Cli/StellaOps.Cli/cli-routes.json b/src/Cli/StellaOps.Cli/cli-routes.json index 1a4675651..15b14396e 100644 --- a/src/Cli/StellaOps.Cli/cli-routes.json +++ b/src/Cli/StellaOps.Cli/cli-routes.json @@ -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", diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs new file mode 100644 index 000000000..390f04e18 --- /dev/null +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs @@ -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(MockBehavior.Strict); + backend + .Setup(client => client.ListIntegrationsAsync( + IntegrationType.Registry, + IntegrationProvider.Harbor, + IntegrationStatus.Active, + "prod", + 1, + 20, + "name", + false, + It.IsAny())) + .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("--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(MockBehavior.Strict); + backend + .Setup(client => client.CreateIntegrationAsync(It.IsAny(), It.IsAny())) + .Callback((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("--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(captured.ExtendedConfig!["project"])); + Assert.True(Assert.IsType(captured.ExtendedConfig["enabled"])); + + backend.VerifyAll(); + } + + [Fact] + public async Task ProvidersCommand_JsonOutput_IncludesDiscoveryMetadata() + { + var backend = new Mock(MockBehavior.Strict); + backend + .Setup(client => client.ListIntegrationProvidersAsync(true, It.IsAny())) + .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("--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(MockBehavior.Strict); + backend + .Setup(client => client.DiscoverIntegrationResourcesAsync( + integrationId, + It.IsAny(), + It.IsAny())) + .Callback((_, request, _) => captured = request) + .ReturnsAsync(new DiscoverIntegrationResponse( + integrationId, + IntegrationDiscoveryResourceTypes.Tags, + [ + new DiscoveredIntegrationResource( + IntegrationDiscoveryResourceTypes.Tags, + "team/api:latest", + "latest", + "team/api", + new Dictionary { ["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("--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 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); +} diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs index 45a8b2817..7a092b9c6 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationEndpoints.cs @@ -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) diff --git a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs index bbaf839e4..607358bea 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/IntegrationService.cs @@ -296,6 +296,109 @@ public sealed class IntegrationService result.Duration); } + public async Task 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 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 GetSupportedProviders() + public IReadOnlyList 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 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; + } } -/// -/// Information about a supported provider. -/// -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 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 SupportedResourceTypes); diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDiscovery.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDiscovery.cs new file mode 100644 index 000000000..1648c37c8 --- /dev/null +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDiscovery.cs @@ -0,0 +1,117 @@ +using StellaOps.Integrations.Core; +using System.Text.RegularExpressions; + +namespace StellaOps.Integrations.Contracts; + +/// +/// Request DTO for integration resource discovery. +/// +public sealed record DiscoverIntegrationRequest( + string ResourceType, + IReadOnlyDictionary? Filter); + +/// +/// Response DTO for integration resource discovery. +/// +public sealed record DiscoverIntegrationResponse( + Guid IntegrationId, + string ResourceType, + IReadOnlyList Resources, + DateTimeOffset DiscoveredAt); + +/// +/// A resource discovered through an integration connector. +/// +public sealed record DiscoveredIntegrationResource( + string ResourceType, + string Id, + string Name, + string? Parent, + IReadOnlyDictionary? Metadata); + +/// +/// Optional plugin capability for resource discovery. +/// +public interface IIntegrationDiscoveryPlugin +{ + /// + /// Resource types supported by the connector. + /// + IReadOnlyList SupportedResourceTypes { get; } + + /// + /// Discover resources exposed by the integration. + /// + Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default); +} + +/// +/// Known discovery resource type ids used by integrations. +/// +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(); + } +} + +/// +/// Shared helpers for discovery filters. +/// +public static class IntegrationDiscoveryFilter +{ + public static string? GetValue(IReadOnlyDictionary? 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); + } +} diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDtos.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDtos.cs index 3eeb2caaa..875f42c44 100644 --- a/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDtos.cs +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Contracts/IntegrationDtos.cs @@ -95,3 +95,38 @@ public sealed record PagedIntegrationsResponse( int Page, int PageSize, int TotalPages); + +/// +/// Information about a supported integration provider. +/// +public sealed record ProviderInfo( + string Name, + IntegrationType Type, + IntegrationProvider Provider, + bool IsTestOnly, + bool SupportsDiscovery, + IReadOnlyList SupportedResourceTypes); + +/// +/// Response DTO for integration impact analysis. +/// +public sealed record IntegrationImpactResponse( + Guid IntegrationId, + string IntegrationName, + IntegrationType Type, + IntegrationProvider Provider, + IntegrationStatus Status, + string Severity, + int BlockingWorkflowCount, + int TotalWorkflowCount, + IReadOnlyList ImpactedWorkflows); + +/// +/// Single impacted workflow entry within integration impact analysis. +/// +public sealed record ImpactedWorkflow( + string Workflow, + string Domain, + bool Blocking, + string Impact, + string RecommendedAction); diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Core/IntegrationEnums.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Core/IntegrationEnums.cs index 6756a7134..cc080a2b0 100644 --- a/src/Integrations/__Libraries/StellaOps.Integrations.Core/IntegrationEnums.cs +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Core/IntegrationEnums.cs @@ -30,7 +30,10 @@ public enum IntegrationType Marketplace = 8, /// Secrets/config management (Vault, Consul, etc.). - SecretsManager = 9 + SecretsManager = 9, + + /// Object storage used for air-gap bundles, exports, and mirrored artifacts. + ObjectStorage = 10 } /// @@ -74,6 +77,9 @@ public enum IntegrationProvider CratesIo = 404, GoProxy = 405, + // Object storage + S3Compatible = 450, + // Runtime hosts EbpfAgent = 500, EtwAgent = 501, diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs index 1d0eee30a..e70a733c6 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/DockerRegistryConnectorPlugin.cs @@ -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.). /// -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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Repositories, + IntegrationDiscoveryResourceTypes.Tags + ]; + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); @@ -130,6 +137,84 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin } } + public async Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? 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> DiscoverRepositoriesAsync( + HttpClient client, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken) + { + var response = await client.GetAsync("/v2/_catalog", cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var catalog = JsonSerializer.Deserialize(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> DiscoverTagsAsync( + HttpClient client, + IReadOnlyDictionary? 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(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 + { + ["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? Repositories { get; set; } } + + private sealed class DockerTagsResponse + { + public string? Name { get; set; } + public List? Tags { get; set; } + } } diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/GitLabContainerRegistryConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/GitLabContainerRegistryConnectorPlugin.cs new file mode 100644 index 000000000..4d969d096 --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.DockerRegistry/GitLabContainerRegistryConnectorPlugin.cs @@ -0,0 +1,46 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; + +namespace StellaOps.Integrations.Plugin.DockerRegistry; + +/// +/// GitLab Container Registry connector backed by the OCI Distribution API. +/// Reuses the generic registry implementation but exposes the GitLab registry provider identity. +/// +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 SupportedResourceTypes => _inner.SupportedResourceTypes; + + public Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => _inner.TestConnectionAsync(config, cancellationToken); + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => _inner.CheckHealthAsync(config, cancellationToken); + + public Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default) + => _inner.DiscoverAsync(config, resourceType, filter, cancellationToken); +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabCiConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabCiConnectorPlugin.cs new file mode 100644 index 000000000..991e4f2ea --- /dev/null +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabCiConnectorPlugin.cs @@ -0,0 +1,46 @@ +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; + +namespace StellaOps.Integrations.Plugin.GitLab; + +/// +/// GitLab CI connector plugin backed by the GitLab v4 API. +/// Reuses the GitLab server connector implementation but advertises the CI/CD provider identity. +/// +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 SupportedResourceTypes => _inner.SupportedResourceTypes; + + public Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => _inner.TestConnectionAsync(config, cancellationToken); + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + => _inner.CheckHealthAsync(config, cancellationToken); + + public Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default) + => _inner.DiscoverAsync(config, resourceType, filter, cancellationToken); +} diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs index 700ce7de6..f1e9cbbda 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.GitLab/GitLabConnectorPlugin.cs @@ -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). /// -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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Projects, + IntegrationDiscoveryResourceTypes.Repositories, + IntegrationDiscoveryResourceTypes.Pipelines + ]; + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); @@ -131,6 +139,124 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin } } + public async Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? 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> DiscoverProjectsAsync( + HttpClient client, + IReadOnlyDictionary? 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 + { + ["path"] = project.PathWithNamespace!, + ["webUrl"] = project.WebUrl ?? string.Empty + })) + .ToList(); + } + + private static async Task> DiscoverRepositoriesAsync( + HttpClient client, + IReadOnlyDictionary? 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 + { + ["projectId"] = project.Id.ToString(), + ["defaultBranch"] = project.DefaultBranch ?? string.Empty + })) + .ToList(); + } + + private static async Task> DiscoverPipelinesAsync( + HttpClient client, + IReadOnlyDictionary? 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>(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 + { + ["status"] = pipeline.Status ?? string.Empty, + ["webUrl"] = pipeline.WebUrl ?? string.Empty + })) + .ToList(); + } + + private static async Task> SearchProjectsAsync( + HttpClient client, + IReadOnlyDictionary? 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>(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; } + } } diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs index a994b5a86..bd4d2fd12 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Gitea/GiteaConnectorPlugin.cs @@ -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. /// -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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Projects, + IntegrationDiscoveryResourceTypes.Repositories + ]; + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); @@ -129,6 +136,56 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin } } + public async Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? 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(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 + { + ["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? 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; } + } } diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs index 68acc0758..ef0fcdd6e 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs @@ -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. /// -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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Repositories, + IntegrationDiscoveryResourceTypes.Tags + ]; + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); @@ -139,6 +146,20 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin } } + public async Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? 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> DiscoverRepositoriesAsync( + IntegrationConfig config, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken) + { + var projects = await ListProjectsAsync(config, cancellationToken); + var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern"); + var repositories = new List(); + + 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 + { + ["project"] = repository.Project + }))); + } + + return repositories + .OrderBy(resource => resource.Parent ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ThenBy(resource => resource.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private async Task> DiscoverTagsAsync( + IntegrationConfig config, + IReadOnlyDictionary? 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 + { + ["digest"] = item.Digest, + ["project"] = split[0], + ["repository"] = repositoryPath + })) + .ToList(); + } + + private async Task> 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>(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> 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>(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? Tags { get; set; } + + [JsonPropertyName("push_time")] public DateTimeOffset? PushTime { get; set; } } diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs index 8ec77f0e5..9359121da 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Jenkins/JenkinsConnectorPlugin.cs @@ -10,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.Jenkins; /// Jenkins CI/CD connector plugin. /// Supports Jenkins REST API. /// -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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Jobs, + IntegrationDiscoveryResourceTypes.Pipelines + ]; + public async Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) { var startTime = _timeProvider.GetUtcNow(); @@ -141,6 +147,56 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin } } + public async Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? 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(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 + { + ["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 + { + ["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? Jobs { get; set; } + } + + private sealed class JenkinsJobDto + { + public string? Name { get; set; } + public string? Url { get; set; } + public string? Color { get; set; } + } } diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/DockerRegistryConnectorPluginTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/DockerRegistryConnectorPluginTests.cs new file mode 100644 index 000000000..02239f479 --- /dev/null +++ b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/DockerRegistryConnectorPluginTests.cs @@ -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 { ["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 + { + ["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 _requestTask; + + private LoopbackHttpFixture(Func 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 responder) => new(responder); + + public async Task WaitForPathAsync() => (await _requestTask).Path; + + public Task WaitForRequestAsync() => _requestTask; + + public void Dispose() + { + try + { + _listener.Stop(); + } + catch + { + } + } + + private async Task HandleSingleRequestAsync(Func 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); + } +} diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/S3CompatibleConnectorPluginTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/S3CompatibleConnectorPluginTests.cs new file mode 100644 index 000000000..1ab57708b --- /dev/null +++ b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/S3CompatibleConnectorPluginTests.cs @@ -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 _requestTask; + + private LoopbackHttpFixture(Func 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 responder) => new(responder); + + public Task WaitForPathAsync() => _requestTask; + + public void Dispose() + { + try + { + _listener.Stop(); + } + catch + { + } + } + + private async Task HandleSingleRequestAsync(Func 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"); + } +} diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj index d8a066dea..6da306ffe 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj +++ b/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj @@ -21,6 +21,8 @@ + + diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs index 838db69c0..3ef053ebe 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationImpactEndpointsTests.cs @@ -128,6 +128,88 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture>( + "/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>( + "/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(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(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(TestContext.Current.CancellationToken); + Assert.NotNull(discovered); + Assert.Equal(["alpha/service", "zeta/service"], discovered!.Resources.Select(resource => resource.Name).ToArray()); + } } public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFactory @@ -150,6 +232,15 @@ public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFacto services.RemoveAll(); services.AddSingleton(); + services.RemoveAll(); + services.AddSingleton(sp => + { + var loader = new IntegrationPluginLoader(sp.GetRequiredService>()); + loader.Register(new FakeInMemoryConnectorPlugin()); + loader.Register(new FakeDiscoveryConnectorPlugin()); + return loader; + }); + services.RemoveAll(); services.RemoveAll>(); services.RemoveAll>(); @@ -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 TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero)); + } + + public Task 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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Repositories + ]; + + public Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero)); + } + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } + + public Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default) + { + IReadOnlyList resources = + [ + new(resourceType, "zeta/service", "zeta/service", "zeta", null), + new(resourceType, "alpha/service", "alpha/service", "alpha", null) + ]; + + return Task.FromResult(resources); + } +} diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs index 00bd8a413..b6648ad61 100644 --- a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationServiceTests.cs @@ -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())) + .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())) + .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())) + .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 SupportedResourceTypes => + [ + IntegrationDiscoveryResourceTypes.Projects, + IntegrationDiscoveryResourceTypes.Repositories + ]; + + public Task TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero)); + } + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } + + public Task> DiscoverAsync( + IntegrationConfig config, + string resourceType, + IReadOnlyDictionary? filter, + CancellationToken cancellationToken = default) + { + IReadOnlyList 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 TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero)); + } + + public Task CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default) + { + return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } + } }