Add integration discovery, GitLab CI/Registry plugins, and CLI catalog command
Introduce IntegrationDiscovery DTOs, GitLabCiConnectorPlugin, GitLabContainerRegistryConnectorPlugin, CLI integrations command group, and expand impact/service test coverage for all connector plugins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
888
src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs
Normal file
888
src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs
Normal file
@@ -0,0 +1,888 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class IntegrationsCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
internal static Command BuildIntegrationsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var integrations = new Command("integrations", "Manage integration catalog entries.");
|
||||
|
||||
integrations.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildProvidersCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildGetCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildCreateCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildUpdateCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildDeleteCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildTestCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildHealthCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildImpactCommand(services, verboseOption, cancellationToken));
|
||||
integrations.Add(BuildDiscoverCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return integrations;
|
||||
}
|
||||
|
||||
private static Command BuildListCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var typeOption = new Option<string?>("--type") { Description = "Filter by integration type." };
|
||||
var providerOption = new Option<string?>("--provider") { Description = "Filter by provider." };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by integration status." };
|
||||
var searchOption = new Option<string?>("--search") { Description = "Search by integration name or description." };
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number (default: 1)." };
|
||||
pageOption.SetDefaultValue(1);
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size (default: 20)." };
|
||||
pageSizeOption.SetDefaultValue(20);
|
||||
var sortByOption = new Option<string>("--sort-by") { Description = "Sort field (default: name)." };
|
||||
sortByOption.SetDefaultValue("name");
|
||||
var descendingOption = new Option<bool>("--descending") { Description = "Sort in descending order." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var list = new Command("list", "List configured integrations.");
|
||||
list.Add(typeOption);
|
||||
list.Add(providerOption);
|
||||
list.Add(statusOption);
|
||||
list.Add(searchOption);
|
||||
list.Add(pageOption);
|
||||
list.Add(pageSizeOption);
|
||||
list.Add(sortByOption);
|
||||
list.Add(descendingOption);
|
||||
list.Add(formatOption);
|
||||
list.Add(verboseOption);
|
||||
|
||||
list.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.ListIntegrationsAsync(
|
||||
ParseOptionalEnum<IntegrationType>(parseResult.GetValue(typeOption)),
|
||||
ParseOptionalEnum<IntegrationProvider>(parseResult.GetValue(providerOption)),
|
||||
ParseOptionalEnum<IntegrationStatus>(parseResult.GetValue(statusOption)),
|
||||
parseResult.GetValue(searchOption),
|
||||
parseResult.GetValue(pageOption),
|
||||
parseResult.GetValue(pageSizeOption),
|
||||
parseResult.GetValue(sortByOption) ?? "name",
|
||||
parseResult.GetValue(descendingOption),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteIntegrationList(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error listing integrations: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Command BuildProvidersCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var includeTestOnlyOption = new Option<bool>("--include-test-only") { Description = "Include test-only providers." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var providers = new Command("providers", "List supported integration providers.");
|
||||
providers.Add(includeTestOnlyOption);
|
||||
providers.Add(formatOption);
|
||||
providers.Add(verboseOption);
|
||||
|
||||
providers.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.ListIntegrationProvidersAsync(
|
||||
parseResult.GetValue(includeTestOnlyOption),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteProviders(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error listing integration providers: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
private static Command BuildGetCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var get = new Command("get", "Show integration details.");
|
||||
get.Aliases.Add("show");
|
||||
get.Add(idArgument);
|
||||
get.Add(formatOption);
|
||||
get.Add(verboseOption);
|
||||
|
||||
get.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.GetIntegrationAsync(parseResult.GetValue(idArgument), cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{parseResult.GetValue(idArgument)}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteIntegrationDetails(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error getting integration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return get;
|
||||
}
|
||||
|
||||
private static Command BuildCreateCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Integration name." };
|
||||
var descriptionOption = new Option<string?>("--description") { Description = "Optional description." };
|
||||
var typeOption = new Option<string>("--type") { Description = "Integration type." };
|
||||
var providerOption = new Option<string>("--provider") { Description = "Integration provider." };
|
||||
var endpointOption = new Option<string>("--endpoint") { Description = "Provider endpoint URL." };
|
||||
var authRefOption = new Option<string?>("--authref") { Description = "Auth reference URI." };
|
||||
var organizationOption = new Option<string?>("--org") { Description = "Optional organization or namespace identifier." };
|
||||
var tagOption = BuildMultiValueOption("--tag", "Tags to attach to the integration.");
|
||||
var configOption = BuildMultiValueOption("--config", "Extended config entries in key=value form.");
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var create = new Command("create", "Create an integration catalog entry.");
|
||||
create.Add(nameOption);
|
||||
create.Add(descriptionOption);
|
||||
create.Add(typeOption);
|
||||
create.Add(providerOption);
|
||||
create.Add(endpointOption);
|
||||
create.Add(authRefOption);
|
||||
create.Add(organizationOption);
|
||||
create.Add(tagOption);
|
||||
create.Add(configOption);
|
||||
create.Add(formatOption);
|
||||
create.Add(verboseOption);
|
||||
|
||||
create.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = parseResult.GetValue(nameOption) ?? string.Empty;
|
||||
var endpoint = parseResult.GetValue(endpointOption) ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
throw new InvalidOperationException("--name and --endpoint are required.");
|
||||
}
|
||||
|
||||
var request = new CreateIntegrationRequest(
|
||||
name,
|
||||
parseResult.GetValue(descriptionOption),
|
||||
ParseRequiredEnum<IntegrationType>(parseResult.GetValue(typeOption), "--type"),
|
||||
ParseRequiredEnum<IntegrationProvider>(parseResult.GetValue(providerOption), "--provider"),
|
||||
endpoint,
|
||||
parseResult.GetValue(authRefOption),
|
||||
parseResult.GetValue(organizationOption),
|
||||
ParseObjectEntries(parseResult.GetValue(configOption), "--config"),
|
||||
NormalizeValues(parseResult.GetValue(tagOption)));
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.CreateIntegrationAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Integration '{response.Name}' created.");
|
||||
Console.WriteLine();
|
||||
WriteIntegrationDetails(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error creating integration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return create;
|
||||
}
|
||||
|
||||
private static Command BuildUpdateCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var nameOption = new Option<string?>("--name") { Description = "Updated name." };
|
||||
var descriptionOption = new Option<string?>("--description") { Description = "Updated description." };
|
||||
var endpointOption = new Option<string?>("--endpoint") { Description = "Updated endpoint." };
|
||||
var authRefOption = new Option<string?>("--authref") { Description = "Updated auth reference URI." };
|
||||
var organizationOption = new Option<string?>("--org") { Description = "Updated organization or namespace identifier." };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Updated status." };
|
||||
var tagOption = BuildMultiValueOption("--tag", "Replacement tags for the integration.");
|
||||
var configOption = BuildMultiValueOption("--config", "Replacement extended config entries in key=value form.");
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var update = new Command("update", "Update an integration catalog entry.");
|
||||
update.Add(idArgument);
|
||||
update.Add(nameOption);
|
||||
update.Add(descriptionOption);
|
||||
update.Add(endpointOption);
|
||||
update.Add(authRefOption);
|
||||
update.Add(organizationOption);
|
||||
update.Add(statusOption);
|
||||
update.Add(tagOption);
|
||||
update.Add(configOption);
|
||||
update.Add(formatOption);
|
||||
update.Add(verboseOption);
|
||||
|
||||
update.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new UpdateIntegrationRequest(
|
||||
parseResult.GetValue(nameOption),
|
||||
parseResult.GetValue(descriptionOption),
|
||||
parseResult.GetValue(endpointOption),
|
||||
parseResult.GetValue(authRefOption),
|
||||
parseResult.GetValue(organizationOption),
|
||||
HasValues(parseResult.GetValue(configOption)) ? ParseObjectEntries(parseResult.GetValue(configOption), "--config") : null,
|
||||
HasValues(parseResult.GetValue(tagOption)) ? NormalizeValues(parseResult.GetValue(tagOption)) : null,
|
||||
ParseOptionalEnum<IntegrationStatus>(parseResult.GetValue(statusOption)));
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.UpdateIntegrationAsync(
|
||||
parseResult.GetValue(idArgument),
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Integration '{response.Name}' updated.");
|
||||
Console.WriteLine();
|
||||
WriteIntegrationDetails(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error updating integration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private static Command BuildDeleteCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
|
||||
var delete = new Command("delete", "Delete an integration catalog entry.");
|
||||
delete.Aliases.Add("remove");
|
||||
delete.Add(idArgument);
|
||||
delete.Add(verboseOption);
|
||||
|
||||
delete.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = parseResult.GetValue(idArgument);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var deleted = await backend.DeleteIntegrationAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{id}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Integration '{id}' deleted.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error deleting integration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return delete;
|
||||
}
|
||||
|
||||
private static Command BuildTestCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var test = new Command("test", "Test integration connectivity.");
|
||||
test.Add(idArgument);
|
||||
test.Add(formatOption);
|
||||
test.Add(verboseOption);
|
||||
|
||||
test.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = parseResult.GetValue(idArgument);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.TestIntegrationAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{id}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteTestResponse(response);
|
||||
}
|
||||
|
||||
return response.Success ? 0 : 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error testing integration: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
private static Command BuildHealthCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var health = new Command("health", "Get current integration health.");
|
||||
health.Add(idArgument);
|
||||
health.Add(formatOption);
|
||||
health.Add(verboseOption);
|
||||
|
||||
health.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = parseResult.GetValue(idArgument);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.GetIntegrationHealthAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{id}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteHealthResponse(response);
|
||||
}
|
||||
|
||||
return response.Status == HealthStatus.Unhealthy ? 1 : 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error retrieving integration health: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
private static Command BuildImpactCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var impact = new Command("impact", "Show workflow impact for an integration.");
|
||||
impact.Add(idArgument);
|
||||
impact.Add(formatOption);
|
||||
impact.Add(verboseOption);
|
||||
|
||||
impact.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = parseResult.GetValue(idArgument);
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.GetIntegrationImpactAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
if (response is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{id}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteImpactResponse(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error retrieving integration impact: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return impact;
|
||||
}
|
||||
|
||||
private static Command BuildDiscoverCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var idArgument = new Argument<Guid>("integration-id") { Description = "Integration ID." };
|
||||
var resourceTypeOption = new Option<string>("--resource-type") { Description = "Resource type to discover." };
|
||||
var filterOption = BuildMultiValueOption("--filter", "Discovery filter entries in key=value form.");
|
||||
var formatOption = BuildFormatOption();
|
||||
|
||||
var discover = new Command("discover", "Discover provider resources for an integration.");
|
||||
discover.Add(idArgument);
|
||||
discover.Add(resourceTypeOption);
|
||||
discover.Add(filterOption);
|
||||
discover.Add(formatOption);
|
||||
discover.Add(verboseOption);
|
||||
|
||||
discover.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = parseResult.GetValue(idArgument);
|
||||
var resourceType = parseResult.GetValue(resourceTypeOption) ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(resourceType))
|
||||
{
|
||||
throw new InvalidOperationException("--resource-type is required.");
|
||||
}
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
var response = await backend.DiscoverIntegrationResourcesAsync(
|
||||
id,
|
||||
new DiscoverIntegrationRequest(resourceType, ParseStringEntries(parseResult.GetValue(filterOption), "--filter")),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Integration '{id}' not found.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var format = RequireFormat(parseResult.GetValue(formatOption));
|
||||
if (IsJson(format))
|
||||
{
|
||||
WriteJson(response);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteDiscoveryResponse(response);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error discovering integration resources: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return discover;
|
||||
}
|
||||
|
||||
private static Option<string> BuildFormatOption()
|
||||
{
|
||||
var option = new Option<string>("--format", "-f")
|
||||
{
|
||||
Description = "Output format: table (default) or json."
|
||||
};
|
||||
option.SetDefaultValue("table");
|
||||
return option;
|
||||
}
|
||||
|
||||
private static Option<string[]> BuildMultiValueOption(string name, string description)
|
||||
{
|
||||
var option = new Option<string[]>(name)
|
||||
{
|
||||
Description = description,
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
option.AllowMultipleArgumentsPerToken = true;
|
||||
option.SetDefaultValue([]);
|
||||
return option;
|
||||
}
|
||||
|
||||
private static string RequireFormat(string? format)
|
||||
{
|
||||
var resolved = string.IsNullOrWhiteSpace(format) ? "table" : format.Trim();
|
||||
if (!resolved.Equals("table", StringComparison.OrdinalIgnoreCase) &&
|
||||
!resolved.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported format '{resolved}'. Use 'table' or 'json'.");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static bool IsJson(string format) => format.Equals("json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void WriteJson<T>(T value)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(value, JsonOutputOptions));
|
||||
}
|
||||
|
||||
private static void WriteIntegrationList(PagedIntegrationsResponse response)
|
||||
{
|
||||
if (response.Items.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No integrations found.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Integrations");
|
||||
Console.WriteLine("============");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var item in response.Items)
|
||||
{
|
||||
Console.WriteLine($"{item.Id} {item.Name}");
|
||||
Console.WriteLine($" type={item.Type} provider={item.Provider} status={item.Status} health={item.LastHealthStatus}");
|
||||
Console.WriteLine($" endpoint={item.Endpoint}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Page {response.Page}/{response.TotalPages} Total: {response.TotalCount}");
|
||||
}
|
||||
|
||||
private static void WriteProviders(IReadOnlyList<ProviderInfo> providers)
|
||||
{
|
||||
if (providers.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No providers available.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Integration Providers");
|
||||
Console.WriteLine("=====================");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
Console.WriteLine($"{provider.Provider} ({provider.Name})");
|
||||
Console.WriteLine($" type={provider.Type} testOnly={provider.IsTestOnly} discovery={provider.SupportsDiscovery}");
|
||||
if (provider.SupportedResourceTypes.Count > 0)
|
||||
{
|
||||
Console.WriteLine($" resources={string.Join(", ", provider.SupportedResourceTypes)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteIntegrationDetails(IntegrationResponse response)
|
||||
{
|
||||
Console.WriteLine($"Id: {response.Id}");
|
||||
Console.WriteLine($"Name: {response.Name}");
|
||||
Console.WriteLine($"Type: {response.Type}");
|
||||
Console.WriteLine($"Provider: {response.Provider}");
|
||||
Console.WriteLine($"Status: {response.Status}");
|
||||
Console.WriteLine($"Endpoint: {response.Endpoint}");
|
||||
Console.WriteLine($"Has Auth: {response.HasAuth}");
|
||||
Console.WriteLine($"Organization: {response.OrganizationId ?? "(none)"}");
|
||||
Console.WriteLine($"Health: {response.LastHealthStatus}");
|
||||
Console.WriteLine($"Health Check: {response.LastHealthCheckAt?.ToString("u", CultureInfo.InvariantCulture) ?? "(never)"}");
|
||||
Console.WriteLine($"Created: {response.CreatedAt:u}");
|
||||
Console.WriteLine($"Updated: {response.UpdatedAt:u}");
|
||||
Console.WriteLine($"Created By: {response.CreatedBy ?? "(unknown)"}");
|
||||
Console.WriteLine($"Updated By: {response.UpdatedBy ?? "(unknown)"}");
|
||||
Console.WriteLine($"Tags: {FormatTags(response.Tags)}");
|
||||
}
|
||||
|
||||
private static void WriteTestResponse(TestConnectionResponse response)
|
||||
{
|
||||
Console.WriteLine($"Integration: {response.IntegrationId}");
|
||||
Console.WriteLine($"Success: {response.Success}");
|
||||
Console.WriteLine($"Message: {response.Message ?? "(none)"}");
|
||||
Console.WriteLine($"Duration: {response.Duration}");
|
||||
|
||||
if (response.Details is { Count: > 0 })
|
||||
{
|
||||
Console.WriteLine("Details:");
|
||||
foreach (var pair in response.Details.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($" {pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteHealthResponse(HealthCheckResponse response)
|
||||
{
|
||||
Console.WriteLine($"Integration: {response.IntegrationId}");
|
||||
Console.WriteLine($"Status: {response.Status}");
|
||||
Console.WriteLine($"Message: {response.Message ?? "(none)"}");
|
||||
Console.WriteLine($"Checked At: {response.CheckedAt:u}");
|
||||
Console.WriteLine($"Duration: {response.Duration}");
|
||||
|
||||
if (response.Details is { Count: > 0 })
|
||||
{
|
||||
Console.WriteLine("Details:");
|
||||
foreach (var pair in response.Details.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($" {pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteImpactResponse(IntegrationImpactResponse response)
|
||||
{
|
||||
Console.WriteLine($"Integration: {response.IntegrationName} ({response.IntegrationId})");
|
||||
Console.WriteLine($"Type: {response.Type}");
|
||||
Console.WriteLine($"Provider: {response.Provider}");
|
||||
Console.WriteLine($"Status: {response.Status}");
|
||||
Console.WriteLine($"Severity: {response.Severity}");
|
||||
Console.WriteLine($"Blocking: {response.BlockingWorkflowCount}/{response.TotalWorkflowCount}");
|
||||
|
||||
if (response.ImpactedWorkflows.Count == 0)
|
||||
{
|
||||
Console.WriteLine("Workflows: none");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Workflows:");
|
||||
foreach (var workflow in response.ImpactedWorkflows)
|
||||
{
|
||||
Console.WriteLine($" {workflow.Workflow} [{workflow.Domain}] blocking={workflow.Blocking}");
|
||||
Console.WriteLine($" impact={workflow.Impact}");
|
||||
Console.WriteLine($" action={workflow.RecommendedAction}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteDiscoveryResponse(DiscoverIntegrationResponse response)
|
||||
{
|
||||
Console.WriteLine($"Integration: {response.IntegrationId}");
|
||||
Console.WriteLine($"Resource Type: {response.ResourceType}");
|
||||
Console.WriteLine($"Discovered At: {response.DiscoveredAt:u}");
|
||||
Console.WriteLine($"Count: {response.Resources.Count}");
|
||||
|
||||
if (response.Resources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Resources:");
|
||||
foreach (var resource in response.Resources)
|
||||
{
|
||||
Console.WriteLine($" {resource.Name} (id={resource.Id})");
|
||||
Console.WriteLine($" parent={resource.Parent ?? "(none)"} type={resource.ResourceType}");
|
||||
if (resource.Metadata is { Count: > 0 })
|
||||
{
|
||||
Console.WriteLine($" metadata={string.Join(", ", resource.Metadata.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(pair => $"{pair.Key}={pair.Value}"))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeValues(string[]? values)
|
||||
{
|
||||
var normalized = (values ?? [])
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return normalized.Length == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static bool HasValues(string[]? values)
|
||||
{
|
||||
return values is { Length: > 0 } && values.Any(value => !string.IsNullOrWhiteSpace(value));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object>? ParseObjectEntries(string[]? entries, string optionName)
|
||||
{
|
||||
if (!HasValues(entries))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries!)
|
||||
{
|
||||
var (key, value) = SplitKeyValue(entry, optionName);
|
||||
result[key] = ParseScalarValue(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? ParseStringEntries(string[]? entries, string optionName)
|
||||
{
|
||||
if (!HasValues(entries))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries!)
|
||||
{
|
||||
var (key, value) = SplitKeyValue(entry, optionName);
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (string Key, string Value) SplitKeyValue(string? entry, string optionName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
throw new InvalidOperationException($"{optionName} entries must use key=value syntax.");
|
||||
}
|
||||
|
||||
var separatorIndex = entry.IndexOf('=');
|
||||
if (separatorIndex <= 0 || separatorIndex == entry.Length - 1)
|
||||
{
|
||||
throw new InvalidOperationException($"{optionName} entry '{entry}' must use key=value syntax.");
|
||||
}
|
||||
|
||||
return (entry[..separatorIndex].Trim(), entry[(separatorIndex + 1)..].Trim());
|
||||
}
|
||||
|
||||
private static object ParseScalarValue(string value)
|
||||
{
|
||||
if (bool.TryParse(value, out var boolValue))
|
||||
{
|
||||
return boolValue;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var doubleValue))
|
||||
{
|
||||
return doubleValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static TEnum ParseRequiredEnum<TEnum>(string? value, string optionName)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var parsed = ParseOptionalEnum<TEnum>(value);
|
||||
if (!parsed.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException($"{optionName} is required and must be a valid {typeof(TEnum).Name} value.");
|
||||
}
|
||||
|
||||
return parsed.Value;
|
||||
}
|
||||
|
||||
private static TEnum? ParseOptionalEnum<TEnum>(string? value)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericValue) &&
|
||||
Enum.IsDefined(typeof(TEnum), numericValue))
|
||||
{
|
||||
return (TEnum)Enum.ToObject(typeof(TEnum), numericValue);
|
||||
}
|
||||
|
||||
var normalized = NormalizeEnumToken(value);
|
||||
foreach (var candidate in Enum.GetValues<TEnum>())
|
||||
{
|
||||
if (NormalizeEnumToken(candidate.ToString()).Equals(normalized, StringComparison.Ordinal))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Invalid {typeof(TEnum).Name} value '{value}'.");
|
||||
}
|
||||
|
||||
private static string NormalizeEnumToken(string value)
|
||||
{
|
||||
return new string(value.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string FormatTags(IReadOnlyList<string> tags)
|
||||
{
|
||||
return tags.Count == 0 ? "(none)" : string.Join(", ", tags.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -44,22 +44,6 @@ public static class NotifyCommandGroup
|
||||
return notifyCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 'integrations' command group.
|
||||
/// </summary>
|
||||
public static Command BuildIntegrationsCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var integrationsCommand = new Command("integrations", "Integration management and testing");
|
||||
|
||||
integrationsCommand.Add(BuildIntegrationsListCommand(services, verboseOption, cancellationToken));
|
||||
integrationsCommand.Add(BuildIntegrationsTestCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return integrationsCommand;
|
||||
}
|
||||
|
||||
#region Channels Commands (NIN-001)
|
||||
|
||||
/// <summary>
|
||||
@@ -470,6 +454,7 @@ public static class NotifyCommandGroup
|
||||
|
||||
#endregion
|
||||
|
||||
#if false
|
||||
#region Integrations Commands (NIN-003)
|
||||
|
||||
private static Command BuildIntegrationsListCommand(
|
||||
@@ -613,6 +598,7 @@ public static class NotifyCommandGroup
|
||||
}
|
||||
|
||||
#endregion
|
||||
#endif
|
||||
|
||||
#region Sample Data
|
||||
|
||||
@@ -640,18 +626,6 @@ public static class NotifyCommandGroup
|
||||
];
|
||||
}
|
||||
|
||||
private static List<Integration> GetIntegrations()
|
||||
{
|
||||
return
|
||||
[
|
||||
new Integration { Id = "github-scm", Type = "scm", Endpoint = "https://github.example.com", Status = "healthy" },
|
||||
new Integration { Id = "gitlab-scm", Type = "scm", Endpoint = "https://gitlab.example.com", Status = "healthy" },
|
||||
new Integration { Id = "harbor-registry", Type = "registry", Endpoint = "https://harbor.example.com", Status = "healthy" },
|
||||
new Integration { Id = "vault-secrets", Type = "secrets", Endpoint = "https://vault.example.com", Status = "degraded" },
|
||||
new Integration { Id = "jenkins-ci", Type = "ci", Endpoint = "https://jenkins.example.com", Status = "healthy" }
|
||||
];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
@@ -687,23 +661,5 @@ public static class NotifyCommandGroup
|
||||
public Dictionary<string, string[]> Events { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class Integration
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class IntegrationTestResult
|
||||
{
|
||||
public string IntegrationId { get; set; } = string.Empty;
|
||||
public bool Passed { get; set; }
|
||||
public string Connectivity { get; set; } = string.Empty;
|
||||
public string Authentication { get; set; } = string.Empty;
|
||||
public int LatencyMs { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -549,19 +549,19 @@ public static class TopologyCommandGroup
|
||||
var scopeTypeOption = new Option<string>("--scope-type", ["-s"])
|
||||
{
|
||||
Description = "Scope type: environment, target",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var scopeIdOption = new Option<string>("--scope-id", ["-i"])
|
||||
{
|
||||
Description = "Scope entity ID",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var integrationOption = new Option<string>("--integration", ["-g"])
|
||||
{
|
||||
Description = "Integration name to bind",
|
||||
IsRequired = true
|
||||
Required = true
|
||||
};
|
||||
|
||||
var formatOption = new Option<string>("--format", ["-f"])
|
||||
|
||||
@@ -8,6 +8,8 @@ using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Digests;
|
||||
using System;
|
||||
@@ -5658,7 +5660,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
|
||||
public async Task<StellaOps.Cli.Services.Models.TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
@@ -5679,7 +5681,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<TestConnectionResult>(json, SerializerOptions)
|
||||
return JsonSerializer.Deserialize<StellaOps.Cli.Services.Models.TestConnectionResult>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Test connection response was empty.");
|
||||
}
|
||||
|
||||
@@ -5716,4 +5718,288 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<PagedIntegrationsResponse> ListIntegrationsAsync(
|
||||
IntegrationType? type,
|
||||
IntegrationProvider? provider,
|
||||
IntegrationStatus? status,
|
||||
string? search,
|
||||
int page,
|
||||
int pageSize,
|
||||
string sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var query = new List<string>
|
||||
{
|
||||
$"page={page}",
|
||||
$"pageSize={pageSize}",
|
||||
$"sortBy={Uri.EscapeDataString(sortBy)}",
|
||||
$"sortDescending={sortDescending.ToString().ToLowerInvariant()}"
|
||||
};
|
||||
|
||||
if (type.HasValue)
|
||||
{
|
||||
query.Add($"type={(int)type.Value}");
|
||||
}
|
||||
|
||||
if (provider.HasValue)
|
||||
{
|
||||
query.Add($"provider={(int)provider.Value}");
|
||||
}
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query.Add($"status={(int)status.Value}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query.Add($"search={Uri.EscapeDataString(search)}");
|
||||
}
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/?{string.Join("&", query)}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to list integrations: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<PagedIntegrationsResponse>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("List integrations response was empty.");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProviderInfo>> ListIntegrationProvidersAsync(bool includeTestOnly, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var suffix = includeTestOnly ? "?includeTestOnly=true" : string.Empty;
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/providers{suffix}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to list integration providers: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<List<ProviderInfo>>(json, SerializerOptions)
|
||||
?? new List<ProviderInfo>(0);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse?> GetIntegrationAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to get integration '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse> CreateIntegrationAsync(CreateIntegrationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "api/v1/integrations/");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to create integration: {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Create integration response was empty.");
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse> UpdateIntegrationAsync(Guid id, UpdateIntegrationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Put, $"api/v1/integrations/{id}");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to update integration '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IntegrationResponse>(json, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Update integration response was empty.");
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteIntegrationAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Delete, $"api/v1/integrations/{id}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to delete integration '{id}': {failure}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResponse?> TestIntegrationAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/integrations/{id}/test");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to test integration '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<TestConnectionResponse>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResponse?> GetIntegrationHealthAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}/health");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to get integration health '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<HealthCheckResponse>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<IntegrationImpactResponse?> GetIntegrationImpactAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Get, $"api/v1/integrations/{id}/impact");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to get integration impact '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<IntegrationImpactResponse>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
public async Task<DiscoverIntegrationResponse?> DiscoverIntegrationResourcesAsync(
|
||||
Guid id,
|
||||
DiscoverIntegrationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
EnsureBackendConfigured();
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, $"api/v1/integrations/{id}/discover");
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<IntegrationDiscoveryErrorResponse>(body, SerializerOptions);
|
||||
var supported = error?.SupportedResourceTypes is { Count: > 0 }
|
||||
? $" Supported resource types: {string.Join(", ", error.SupportedResourceTypes)}."
|
||||
: string.Empty;
|
||||
throw new InvalidOperationException($"{error?.Error ?? "Integration discovery failed."}{supported}");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidOperationException(body);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to discover integration resources for '{id}': {failure}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<DiscoverIntegrationResponse>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
private sealed class IntegrationDiscoveryErrorResponse
|
||||
{
|
||||
public string? Error { get; init; }
|
||||
|
||||
public List<string> SupportedResourceTypes { get; init; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
@@ -172,7 +174,28 @@ internal interface IBackendOperationsClient
|
||||
Task<IdentityProviderDto> CreateIdentityProviderAsync(CreateIdentityProviderRequest request, CancellationToken cancellationToken);
|
||||
Task<IdentityProviderDto> UpdateIdentityProviderAsync(Guid id, UpdateIdentityProviderRequest request, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken);
|
||||
Task<StellaOps.Cli.Services.Models.TestConnectionResult> TestIdentityProviderConnectionAsync(TestConnectionRequest request, CancellationToken cancellationToken);
|
||||
Task<bool> EnableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<bool> DisableIdentityProviderAsync(Guid id, CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedIntegrationsResponse> ListIntegrationsAsync(
|
||||
IntegrationType? type,
|
||||
IntegrationProvider? provider,
|
||||
IntegrationStatus? status,
|
||||
string? search,
|
||||
int page,
|
||||
int pageSize,
|
||||
string sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ProviderInfo>> ListIntegrationProvidersAsync(bool includeTestOnly, CancellationToken cancellationToken);
|
||||
Task<IntegrationResponse?> GetIntegrationAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<IntegrationResponse> CreateIntegrationAsync(CreateIntegrationRequest request, CancellationToken cancellationToken);
|
||||
Task<IntegrationResponse> UpdateIntegrationAsync(Guid id, UpdateIntegrationRequest request, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteIntegrationAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<TestConnectionResponse?> TestIntegrationAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<HealthCheckResponse?> GetIntegrationHealthAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<IntegrationImpactResponse?> GetIntegrationImpactAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<DiscoverIntegrationResponse?> DiscoverIntegrationResourcesAsync(Guid id, DiscoverIntegrationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Interop/StellaOps.Policy.Interop.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../Integrations/__Libraries/StellaOps.Integrations.Core/StellaOps.Integrations.Core.csproj" />
|
||||
<ProjectReference Include="../../Integrations/__Libraries/StellaOps.Integrations.Contracts/StellaOps.Integrations.Contracts.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj" />
|
||||
@@ -181,4 +183,3 @@
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class IntegrationsCommandGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListCommand_JsonOutput_CallsBackendAndEmitsEnumsAsStrings()
|
||||
{
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.ListIntegrationsAsync(
|
||||
IntegrationType.Registry,
|
||||
IntegrationProvider.Harbor,
|
||||
IntegrationStatus.Active,
|
||||
"prod",
|
||||
1,
|
||||
20,
|
||||
"name",
|
||||
false,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PagedIntegrationsResponse(
|
||||
[
|
||||
new IntegrationResponse(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000101"),
|
||||
"Prod Harbor",
|
||||
"registry",
|
||||
IntegrationType.Registry,
|
||||
IntegrationProvider.Harbor,
|
||||
IntegrationStatus.Active,
|
||||
"https://harbor.example.com",
|
||||
true,
|
||||
"platform",
|
||||
HealthStatus.Healthy,
|
||||
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse("2026-04-04T07:00:00Z", CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse("2026-04-04T07:30:00Z", CultureInfo.InvariantCulture),
|
||||
"ops",
|
||||
"ops",
|
||||
["prod"])
|
||||
],
|
||||
1,
|
||||
1,
|
||||
20,
|
||||
1));
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"integrations list --type registry --provider harbor --status active --search prod --format json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
|
||||
using var document = JsonDocument.Parse(invocation.StdOut);
|
||||
var item = document.RootElement.GetProperty("items")[0];
|
||||
Assert.Equal("Prod Harbor", item.GetProperty("name").GetString());
|
||||
Assert.Equal("Registry", item.GetProperty("type").GetString());
|
||||
Assert.Equal("Harbor", item.GetProperty("provider").GetString());
|
||||
Assert.Equal("Active", item.GetProperty("status").GetString());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCommand_BuildsLiveCreateRequest()
|
||||
{
|
||||
CreateIntegrationRequest? captured = null;
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.CreateIntegrationAsync(It.IsAny<CreateIntegrationRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<CreateIntegrationRequest, CancellationToken>((request, _) => captured = request)
|
||||
.ReturnsAsync(new IntegrationResponse(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000102"),
|
||||
"GitLab Prod",
|
||||
"scm",
|
||||
IntegrationType.Scm,
|
||||
IntegrationProvider.GitLabServer,
|
||||
IntegrationStatus.Pending,
|
||||
"https://gitlab.example.com",
|
||||
true,
|
||||
"team-a",
|
||||
HealthStatus.Unknown,
|
||||
null,
|
||||
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse("2026-04-04T08:00:00Z", CultureInfo.InvariantCulture),
|
||||
"ops",
|
||||
"ops",
|
||||
["prod", "scm"]));
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"integrations create --name \"GitLab Prod\" --type scm --provider gitlab-server --endpoint https://gitlab.example.com --authref authref://vault/gitlab#token --org team-a --tag prod scm --config project=platform enabled=true");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("GitLab Prod", captured!.Name);
|
||||
Assert.Equal(IntegrationType.Scm, captured.Type);
|
||||
Assert.Equal(IntegrationProvider.GitLabServer, captured.Provider);
|
||||
Assert.Equal("https://gitlab.example.com", captured.Endpoint);
|
||||
Assert.Equal("authref://vault/gitlab#token", captured.AuthRefUri);
|
||||
Assert.Equal("team-a", captured.OrganizationId);
|
||||
Assert.Equal(["prod", "scm"], captured.Tags);
|
||||
Assert.Equal("platform", Assert.IsType<string>(captured.ExtendedConfig!["project"]));
|
||||
Assert.True(Assert.IsType<bool>(captured.ExtendedConfig["enabled"]));
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvidersCommand_JsonOutput_IncludesDiscoveryMetadata()
|
||||
{
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.ListIntegrationProvidersAsync(true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new ProviderInfo(
|
||||
"harbor",
|
||||
IntegrationType.Registry,
|
||||
IntegrationProvider.Harbor,
|
||||
false,
|
||||
true,
|
||||
[IntegrationDiscoveryResourceTypes.Repositories, IntegrationDiscoveryResourceTypes.Tags]),
|
||||
new ProviderInfo(
|
||||
"inmemory",
|
||||
IntegrationType.Registry,
|
||||
IntegrationProvider.InMemory,
|
||||
true,
|
||||
false,
|
||||
[])
|
||||
]);
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "integrations providers --include-test-only --format json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
using var document = JsonDocument.Parse(invocation.StdOut);
|
||||
Assert.Equal(2, document.RootElement.GetArrayLength());
|
||||
Assert.True(document.RootElement[0].GetProperty("supportsDiscovery").GetBoolean());
|
||||
Assert.True(document.RootElement[1].GetProperty("isTestOnly").GetBoolean());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCommand_BuildsFilterDictionary()
|
||||
{
|
||||
DiscoverIntegrationRequest? captured = null;
|
||||
var integrationId = Guid.Parse("00000000-0000-0000-0000-000000000103");
|
||||
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.DiscoverIntegrationResourcesAsync(
|
||||
integrationId,
|
||||
It.IsAny<DiscoverIntegrationRequest>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<Guid, DiscoverIntegrationRequest, CancellationToken>((_, request, _) => captured = request)
|
||||
.ReturnsAsync(new DiscoverIntegrationResponse(
|
||||
integrationId,
|
||||
IntegrationDiscoveryResourceTypes.Tags,
|
||||
[
|
||||
new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Tags,
|
||||
"team/api:latest",
|
||||
"latest",
|
||||
"team/api",
|
||||
new Dictionary<string, string> { ["repository"] = "team/api" })
|
||||
],
|
||||
DateTimeOffset.Parse("2026-04-04T08:15:00Z", CultureInfo.InvariantCulture)));
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option<bool>("--verbose"), CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
$"integrations discover {integrationId} --resource-type tags --filter repository=team/api namePattern=latest --format json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(IntegrationDiscoveryResourceTypes.Tags, captured!.ResourceType);
|
||||
Assert.Equal("team/api", captured.Filter!["repository"]);
|
||||
Assert.Equal("latest", captured.Filter["namePattern"]);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
private static async Task<CommandInvocationResult> InvokeWithCapturedConsoleAsync(RootCommand root, string commandLine)
|
||||
{
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
var originalExitCode = Environment.ExitCode;
|
||||
Environment.ExitCode = 0;
|
||||
|
||||
var stdout = new StringWriter(CultureInfo.InvariantCulture);
|
||||
var stderr = new StringWriter(CultureInfo.InvariantCulture);
|
||||
|
||||
try
|
||||
{
|
||||
Console.SetOut(stdout);
|
||||
Console.SetError(stderr);
|
||||
var exitCode = await root.Parse(commandLine).InvokeAsync();
|
||||
var capturedExitCode = Environment.ExitCode != 0 ? Environment.ExitCode : exitCode;
|
||||
return new CommandInvocationResult(capturedExitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
Environment.ExitCode = originalExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr);
|
||||
}
|
||||
@@ -137,6 +137,34 @@ public static class IntegrationEndpoints
|
||||
.WithName("TestIntegrationConnection")
|
||||
.WithDescription(_t("integrations.integration.test_description"));
|
||||
|
||||
// Discover resources
|
||||
group.MapPost("/{id:guid}/discover", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
Guid id,
|
||||
[FromBody] DiscoverIntegrationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.DiscoverAsync(id, request, tenantAccessor.TenantId, cancellationToken);
|
||||
return result.Status switch
|
||||
{
|
||||
DiscoveryExecutionStatus.Success => Results.Ok(result.Response),
|
||||
DiscoveryExecutionStatus.IntegrationNotFound => Results.NotFound(),
|
||||
DiscoveryExecutionStatus.Unsupported => Results.BadRequest(new
|
||||
{
|
||||
error = result.Message,
|
||||
supportedResourceTypes = result.SupportedResourceTypes
|
||||
}),
|
||||
_ => Results.Problem(
|
||||
title: "Integration discovery failed",
|
||||
detail: result.Message,
|
||||
statusCode: StatusCodes.Status502BadGateway)
|
||||
};
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Operate)
|
||||
.WithName("DiscoverIntegrationResources")
|
||||
.WithDescription("Discover resources exposed by the integration provider.");
|
||||
|
||||
// Health check
|
||||
group.MapGet("/{id:guid}/health", async (
|
||||
[FromServices] IntegrationService service,
|
||||
@@ -166,9 +194,11 @@ public static class IntegrationEndpoints
|
||||
.WithDescription(_t("integrations.integration.impact_description"));
|
||||
|
||||
// Get supported providers
|
||||
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
|
||||
group.MapGet("/providers", (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromQuery] bool includeTestOnly = false) =>
|
||||
{
|
||||
var result = service.GetSupportedProviders();
|
||||
var result = service.GetSupportedProviders(includeTestOnly);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Read)
|
||||
|
||||
@@ -296,6 +296,109 @@ public sealed class IntegrationService
|
||||
result.Duration);
|
||||
}
|
||||
|
||||
public async Task<DiscoveryExecutionResult> DiscoverAsync(
|
||||
Guid id,
|
||||
DiscoverIntegrationRequest request,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null)
|
||||
{
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.IntegrationNotFound,
|
||||
null,
|
||||
"Integration was not found in the current tenant scope.",
|
||||
[]);
|
||||
}
|
||||
|
||||
var discoveryPlugin = _pluginLoader.GetDiscoveryByProvider(integration.Provider);
|
||||
if (discoveryPlugin is null)
|
||||
{
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.Unsupported,
|
||||
null,
|
||||
$"Provider {integration.Provider} does not support resource discovery.",
|
||||
[]);
|
||||
}
|
||||
|
||||
var normalizedResourceType = IntegrationDiscoveryResourceTypes.Normalize(request.ResourceType);
|
||||
if (string.IsNullOrWhiteSpace(normalizedResourceType))
|
||||
{
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.Unsupported,
|
||||
null,
|
||||
"resourceType is required.",
|
||||
discoveryPlugin.SupportedResourceTypes
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
var supportedResourceTypes = discoveryPlugin.SupportedResourceTypes
|
||||
.Select(IntegrationDiscoveryResourceTypes.Normalize)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (!supportedResourceTypes.Contains(normalizedResourceType, StringComparer.Ordinal))
|
||||
{
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.Unsupported,
|
||||
null,
|
||||
$"Provider {integration.Provider} does not support discovery for resource type '{normalizedResourceType}'.",
|
||||
supportedResourceTypes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var resolvedSecret = integration.AuthRefUri is not null
|
||||
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
|
||||
: null;
|
||||
|
||||
var config = BuildConfig(integration, resolvedSecret);
|
||||
var resources = await discoveryPlugin.DiscoverAsync(
|
||||
config,
|
||||
normalizedResourceType,
|
||||
request.Filter,
|
||||
cancellationToken);
|
||||
|
||||
var orderedResources = resources
|
||||
.OrderBy(resource => resource.ResourceType, StringComparer.Ordinal)
|
||||
.ThenBy(resource => resource.Parent ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(resource => resource.Name, StringComparer.Ordinal)
|
||||
.ThenBy(resource => resource.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.Success,
|
||||
new DiscoverIntegrationResponse(
|
||||
integration.Id,
|
||||
normalizedResourceType,
|
||||
orderedResources,
|
||||
_timeProvider.GetUtcNow()),
|
||||
null,
|
||||
supportedResourceTypes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Resource discovery failed for integration {IntegrationId} ({Provider}) and resource type {ResourceType}",
|
||||
integration.Id,
|
||||
integration.Provider,
|
||||
normalizedResourceType);
|
||||
|
||||
return new DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus.Failed,
|
||||
null,
|
||||
ex.Message,
|
||||
supportedResourceTypes);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
@@ -321,12 +424,34 @@ public sealed class IntegrationService
|
||||
ImpactedWorkflows: impactedWorkflows);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
|
||||
public IReadOnlyList<ProviderInfo> GetSupportedProviders(bool includeTestOnly = false)
|
||||
{
|
||||
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
|
||||
p.Name,
|
||||
p.Type,
|
||||
p.Provider)).ToList();
|
||||
return _pluginLoader.Plugins
|
||||
.GroupBy(plugin => plugin.Provider)
|
||||
.Select(group => group.First())
|
||||
.Where(plugin => includeTestOnly || !IsTestOnlyProvider(plugin.Provider))
|
||||
.Select(plugin =>
|
||||
{
|
||||
var discoveryPlugin = plugin as IIntegrationDiscoveryPlugin;
|
||||
var supportedResourceTypes = discoveryPlugin?.SupportedResourceTypes
|
||||
.Select(IntegrationDiscoveryResourceTypes.Normalize)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
return new ProviderInfo(
|
||||
plugin.Name,
|
||||
plugin.Type,
|
||||
plugin.Provider,
|
||||
IsTestOnlyProvider(plugin.Provider),
|
||||
discoveryPlugin is not null,
|
||||
supportedResourceTypes);
|
||||
})
|
||||
.OrderBy(info => info.Type)
|
||||
.ThenBy(info => info.Provider)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<Integration?> GetScopedIntegrationAsync(Guid id, string? tenantId, CancellationToken cancellationToken)
|
||||
@@ -375,6 +500,11 @@ public sealed class IntegrationService
|
||||
new ImpactedWorkflow("dependency-resolution", "security-risk", blockedByStatus, "Package advisory resolution and normalization.", "verify-repository-mirror"),
|
||||
new ImpactedWorkflow("hot-lookup-projection", "security-risk", blockedByStatus, "Hot-lookup enrichment for findings explorer.", "resync-package-index"),
|
||||
],
|
||||
IntegrationType.ObjectStorage =>
|
||||
[
|
||||
new ImpactedWorkflow("airgap-bundle-staging", "platform-ops", blockedByStatus, "Air-gap bundle staging, export, and import checkpoints.", "revalidate-object-storage-endpoint"),
|
||||
new ImpactedWorkflow("evidence-export", "evidence", blockedByStatus, "Evidence pack archival and download handoff storage.", "rerun-export-upload"),
|
||||
],
|
||||
IntegrationType.RuntimeHost =>
|
||||
[
|
||||
new ImpactedWorkflow("runtime-reachability", "security-risk", blockedByStatus, "Runtime witness ingestion for reachability confidence.", "restart-runtime-agent"),
|
||||
@@ -437,27 +567,23 @@ public sealed class IntegrationService
|
||||
integration.UpdatedBy,
|
||||
integration.Tags);
|
||||
}
|
||||
|
||||
private static bool IsTestOnlyProvider(IntegrationProvider provider)
|
||||
{
|
||||
return provider == IntegrationProvider.InMemory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a supported provider.
|
||||
/// </summary>
|
||||
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);
|
||||
public enum DiscoveryExecutionStatus
|
||||
{
|
||||
Success = 0,
|
||||
IntegrationNotFound = 1,
|
||||
Unsupported = 2,
|
||||
Failed = 3
|
||||
}
|
||||
|
||||
public sealed record IntegrationImpactResponse(
|
||||
Guid IntegrationId,
|
||||
string IntegrationName,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
IntegrationStatus Status,
|
||||
string Severity,
|
||||
int BlockingWorkflowCount,
|
||||
int TotalWorkflowCount,
|
||||
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
|
||||
|
||||
public sealed record ImpactedWorkflow(
|
||||
string Workflow,
|
||||
string Domain,
|
||||
bool Blocking,
|
||||
string Impact,
|
||||
string RecommendedAction);
|
||||
public sealed record DiscoveryExecutionResult(
|
||||
DiscoveryExecutionStatus Status,
|
||||
DiscoverIntegrationResponse? Response,
|
||||
string? Message,
|
||||
IReadOnlyList<string> SupportedResourceTypes);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Integrations.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for integration resource discovery.
|
||||
/// </summary>
|
||||
public sealed record DiscoverIntegrationRequest(
|
||||
string ResourceType,
|
||||
IReadOnlyDictionary<string, string>? Filter);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for integration resource discovery.
|
||||
/// </summary>
|
||||
public sealed record DiscoverIntegrationResponse(
|
||||
Guid IntegrationId,
|
||||
string ResourceType,
|
||||
IReadOnlyList<DiscoveredIntegrationResource> Resources,
|
||||
DateTimeOffset DiscoveredAt);
|
||||
|
||||
/// <summary>
|
||||
/// A resource discovered through an integration connector.
|
||||
/// </summary>
|
||||
public sealed record DiscoveredIntegrationResource(
|
||||
string ResourceType,
|
||||
string Id,
|
||||
string Name,
|
||||
string? Parent,
|
||||
IReadOnlyDictionary<string, string>? Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Optional plugin capability for resource discovery.
|
||||
/// </summary>
|
||||
public interface IIntegrationDiscoveryPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Resource types supported by the connector.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedResourceTypes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Discover resources exposed by the integration.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known discovery resource type ids used by integrations.
|
||||
/// </summary>
|
||||
public static class IntegrationDiscoveryResourceTypes
|
||||
{
|
||||
public const string Jobs = "jobs";
|
||||
public const string Pipelines = "pipelines";
|
||||
public const string Projects = "projects";
|
||||
public const string Repositories = "repositories";
|
||||
public const string Tags = "tags";
|
||||
|
||||
public static string Normalize(string resourceType)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(resourceType)
|
||||
? string.Empty
|
||||
: resourceType.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for discovery filters.
|
||||
/// </summary>
|
||||
public static class IntegrationDiscoveryFilter
|
||||
{
|
||||
public static string? GetValue(IReadOnlyDictionary<string, string>? filter, string key)
|
||||
{
|
||||
if (filter is null || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var entry in filter)
|
||||
{
|
||||
if (string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(entry.Value)
|
||||
? null
|
||||
: entry.Value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool MatchesNamePattern(string candidate, string? pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmedPattern = pattern.Trim();
|
||||
if (!trimmedPattern.Contains('*', StringComparison.Ordinal))
|
||||
{
|
||||
return candidate.Contains(trimmedPattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(trimmedPattern).Replace("\\*", ".*", StringComparison.Ordinal) + "$";
|
||||
return Regex.IsMatch(candidate, regexPattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,38 @@ public sealed record PagedIntegrationsResponse(
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalPages);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a supported integration provider.
|
||||
/// </summary>
|
||||
public sealed record ProviderInfo(
|
||||
string Name,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
bool IsTestOnly,
|
||||
bool SupportsDiscovery,
|
||||
IReadOnlyList<string> SupportedResourceTypes);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for integration impact analysis.
|
||||
/// </summary>
|
||||
public sealed record IntegrationImpactResponse(
|
||||
Guid IntegrationId,
|
||||
string IntegrationName,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
IntegrationStatus Status,
|
||||
string Severity,
|
||||
int BlockingWorkflowCount,
|
||||
int TotalWorkflowCount,
|
||||
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
|
||||
|
||||
/// <summary>
|
||||
/// Single impacted workflow entry within integration impact analysis.
|
||||
/// </summary>
|
||||
public sealed record ImpactedWorkflow(
|
||||
string Workflow,
|
||||
string Domain,
|
||||
bool Blocking,
|
||||
string Impact,
|
||||
string RecommendedAction);
|
||||
|
||||
@@ -30,7 +30,10 @@ public enum IntegrationType
|
||||
Marketplace = 8,
|
||||
|
||||
/// <summary>Secrets/config management (Vault, Consul, etc.).</summary>
|
||||
SecretsManager = 9
|
||||
SecretsManager = 9,
|
||||
|
||||
/// <summary>Object storage used for air-gap bundles, exports, and mirrored artifacts.</summary>
|
||||
ObjectStorage = 10
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,6 +77,9 @@ public enum IntegrationProvider
|
||||
CratesIo = 404,
|
||||
GoProxy = 405,
|
||||
|
||||
// Object storage
|
||||
S3Compatible = 450,
|
||||
|
||||
// Runtime hosts
|
||||
EbpfAgent = 500,
|
||||
EtwAgent = 501,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
/// Docker Registry (OCI Distribution) connector plugin.
|
||||
/// Supports any OCI Distribution Spec-compliant registry (Docker Hub, self-hosted registry:2, etc.).
|
||||
/// </summary>
|
||||
public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -31,6 +32,12 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
IntegrationDiscoveryResourceTypes.Tags
|
||||
];
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
@@ -130,6 +137,84 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
|
||||
{
|
||||
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(client, filter, cancellationToken),
|
||||
IntegrationDiscoveryResourceTypes.Tags => await DiscoverTagsAsync(client, filter, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await client.GetAsync("/v2/_catalog", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var catalog = JsonSerializer.Deserialize<DockerCatalogResponse>(content, JsonOptions);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
|
||||
return (catalog?.Repositories ?? [])
|
||||
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository, namePattern))
|
||||
.OrderBy(repository => repository, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(repository => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
repository,
|
||||
repository,
|
||||
Parent: null,
|
||||
Metadata: null))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverTagsAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var repository = IntegrationDiscoveryFilter.GetValue(filter, "repository");
|
||||
if (string.IsNullOrWhiteSpace(repository))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var repositoryPath = string.Join(
|
||||
'/',
|
||||
repository.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Uri.EscapeDataString));
|
||||
|
||||
var response = await client.GetAsync($"/v2/{repositoryPath}/tags/list", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var tags = JsonSerializer.Deserialize<DockerTagsResponse>(content, JsonOptions);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
|
||||
return (tags?.Tags ?? [])
|
||||
.Where(tag => IntegrationDiscoveryFilter.MatchesNamePattern(tag, namePattern))
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(tag => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Tags,
|
||||
$"{repository}:{tag}",
|
||||
tag,
|
||||
Parent: repository,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = repository
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
@@ -140,10 +225,17 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
// Docker Registry uses Bearer token authentication if provided
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
if (!string.IsNullOrWhiteSpace(config.ResolvedSecret))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
if (config.ResolvedSecret.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
else
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
@@ -160,4 +252,10 @@ public sealed class DockerRegistryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public List<string>? Repositories { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DockerTagsResponse
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// GitLab Container Registry connector backed by the OCI Distribution API.
|
||||
/// Reuses the generic registry implementation but exposes the GitLab registry provider identity.
|
||||
/// </summary>
|
||||
public sealed class GitLabContainerRegistryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly DockerRegistryConnectorPlugin _inner;
|
||||
|
||||
public GitLabContainerRegistryConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public GitLabContainerRegistryConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new DockerRegistryConnectorPlugin(timeProvider);
|
||||
}
|
||||
|
||||
public string Name => "gitlab-container-registry";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.GitLabContainerRegistry;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => _inner.IsAvailable(services);
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes => _inner.SupportedResourceTypes;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> _inner.TestConnectionAsync(config, cancellationToken);
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> _inner.CheckHealthAsync(config, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _inner.DiscoverAsync(config, resourceType, filter, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitLab;
|
||||
|
||||
/// <summary>
|
||||
/// GitLab CI connector plugin backed by the GitLab v4 API.
|
||||
/// Reuses the GitLab server connector implementation but advertises the CI/CD provider identity.
|
||||
/// </summary>
|
||||
public sealed class GitLabCiConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly GitLabConnectorPlugin _inner;
|
||||
|
||||
public GitLabCiConnectorPlugin()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public GitLabCiConnectorPlugin(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_inner = new GitLabConnectorPlugin(timeProvider);
|
||||
}
|
||||
|
||||
public string Name => "gitlab-ci";
|
||||
|
||||
public IntegrationType Type => IntegrationType.CiCd;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.GitLabCi;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => _inner.IsAvailable(services);
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes => _inner.SupportedResourceTypes;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> _inner.TestConnectionAsync(config, cancellationToken);
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
=> _inner.CheckHealthAsync(config, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _inner.DiscoverAsync(config, resourceType, filter, cancellationToken);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitLab;
|
||||
|
||||
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.GitLab;
|
||||
/// GitLab Server SCM connector plugin.
|
||||
/// Supports GitLab v4 API (self-managed instances).
|
||||
/// </summary>
|
||||
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -31,6 +32,13 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
IntegrationDiscoveryResourceTypes.Pipelines
|
||||
];
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
@@ -131,6 +139,124 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
|
||||
{
|
||||
IntegrationDiscoveryResourceTypes.Projects => await DiscoverProjectsAsync(client, filter, cancellationToken),
|
||||
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(client, filter, cancellationToken),
|
||||
IntegrationDiscoveryResourceTypes.Pipelines => await DiscoverPipelinesAsync(client, filter, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverProjectsAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await SearchProjectsAsync(client, filter, cancellationToken);
|
||||
|
||||
return projects
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project.PathWithNamespace))
|
||||
.OrderBy(project => project.PathWithNamespace, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(project => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
project.Id.ToString(),
|
||||
project.PathWithNamespace!,
|
||||
Parent: project.Namespace?.FullPath,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["path"] = project.PathWithNamespace!,
|
||||
["webUrl"] = project.WebUrl ?? string.Empty
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await SearchProjectsAsync(client, filter, cancellationToken);
|
||||
|
||||
return projects
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project.PathWithNamespace))
|
||||
.OrderBy(project => project.PathWithNamespace, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(project => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
project.PathWithNamespace!,
|
||||
project.PathWithNamespace!,
|
||||
Parent: project.Namespace?.FullPath,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["projectId"] = project.Id.ToString(),
|
||||
["defaultBranch"] = project.DefaultBranch ?? string.Empty
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverPipelinesAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectRef = IntegrationDiscoveryFilter.GetValue(filter, "projectId")
|
||||
?? IntegrationDiscoveryFilter.GetValue(filter, "project");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(projectRef))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var response = await client.GetAsync($"/api/v4/projects/{Uri.EscapeDataString(projectRef)}/pipelines?per_page=100", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var pipelines = JsonSerializer.Deserialize<List<GitLabPipelineDto>>(content, JsonOptions);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
|
||||
return (pipelines ?? [])
|
||||
.Where(pipeline => IntegrationDiscoveryFilter.MatchesNamePattern(pipeline.Ref ?? pipeline.Id.ToString(), namePattern))
|
||||
.OrderBy(pipeline => pipeline.Ref ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(pipeline => pipeline.Id)
|
||||
.Select(pipeline => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Pipelines,
|
||||
pipeline.Id.ToString(),
|
||||
pipeline.Ref ?? pipeline.Id.ToString(),
|
||||
Parent: projectRef,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["status"] = pipeline.Status ?? string.Empty,
|
||||
["webUrl"] = pipeline.WebUrl ?? string.Empty
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<GitLabProjectDto>> SearchProjectsAsync(
|
||||
HttpClient client,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
var query = namePattern?.Replace("*", string.Empty, StringComparison.Ordinal).Trim() ?? string.Empty;
|
||||
var response = await client.GetAsync($"/api/v4/projects?simple=true&per_page=100&search={Uri.EscapeDataString(query)}", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var projects = JsonSerializer.Deserialize<List<GitLabProjectDto>>(content, JsonOptions);
|
||||
|
||||
return (projects ?? [])
|
||||
.Where(project => IntegrationDiscoveryFilter.MatchesNamePattern(project.PathWithNamespace ?? string.Empty, namePattern))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
@@ -162,4 +288,36 @@ public sealed class GitLabConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public string? Version { get; set; }
|
||||
public string? Revision { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitLabProjectDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("path_with_namespace")]
|
||||
public string? PathWithNamespace { get; set; }
|
||||
|
||||
[JsonPropertyName("web_url")]
|
||||
public string? WebUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("default_branch")]
|
||||
public string? DefaultBranch { get; set; }
|
||||
|
||||
public GitLabNamespaceDto? Namespace { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitLabNamespaceDto
|
||||
{
|
||||
[JsonPropertyName("full_path")]
|
||||
public string? FullPath { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitLabPipelineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Ref { get; set; }
|
||||
|
||||
[JsonPropertyName("web_url")]
|
||||
public string? WebUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Gitea;
|
||||
|
||||
@@ -9,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.Gitea;
|
||||
/// Gitea SCM connector plugin.
|
||||
/// Supports Gitea v1.x API.
|
||||
/// </summary>
|
||||
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -31,6 +32,12 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
IntegrationDiscoveryResourceTypes.Repositories
|
||||
];
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
@@ -129,6 +136,56 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
var query = namePattern?.Replace("*", string.Empty, StringComparison.Ordinal).Trim() ?? string.Empty;
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/repos/search?limit=100&q={Uri.EscapeDataString(query)}", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var searchResponse = JsonSerializer.Deserialize<GiteaRepositorySearchResponse>(content, JsonOptions);
|
||||
var repositories = searchResponse?.Data ?? [];
|
||||
|
||||
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
|
||||
{
|
||||
IntegrationDiscoveryResourceTypes.Projects => repositories
|
||||
.Select(repository => repository.Owner?.Login)
|
||||
.Where(owner => !string.IsNullOrWhiteSpace(owner))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(owner => IntegrationDiscoveryFilter.MatchesNamePattern(owner!, namePattern))
|
||||
.OrderBy(owner => owner, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(owner => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
owner!,
|
||||
owner!,
|
||||
Parent: null,
|
||||
Metadata: null))
|
||||
.ToList(),
|
||||
IntegrationDiscoveryResourceTypes.Repositories => repositories
|
||||
.Where(repository => !string.IsNullOrWhiteSpace(repository.FullName))
|
||||
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository.FullName!, namePattern))
|
||||
.OrderBy(repository => repository.FullName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(repository => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
repository.FullName!,
|
||||
repository.FullName!,
|
||||
Parent: repository.Owner?.Login,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["defaultBranch"] = repository.DefaultBranch ?? string.Empty
|
||||
}))
|
||||
.ToList(),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
@@ -159,4 +216,25 @@ public sealed class GiteaConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaRepositorySearchResponse
|
||||
{
|
||||
public List<GiteaRepositoryDto>? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaRepositoryDto
|
||||
{
|
||||
[JsonPropertyName("full_name")]
|
||||
public string? FullName { get; set; }
|
||||
|
||||
[JsonPropertyName("default_branch")]
|
||||
public string? DefaultBranch { get; set; }
|
||||
|
||||
public GiteaOwnerDto? Owner { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaOwnerDto
|
||||
{
|
||||
public string? Login { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.Integrations.Core;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Harbor;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace StellaOps.Integrations.Plugin.Harbor;
|
||||
/// Harbor container registry connector plugin.
|
||||
/// Supports Harbor v2.x API.
|
||||
/// </summary>
|
||||
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -33,6 +34,12 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
IntegrationDiscoveryResourceTypes.Tags
|
||||
];
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
@@ -139,6 +146,20 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
|
||||
{
|
||||
IntegrationDiscoveryResourceTypes.Repositories => await DiscoverRepositoriesAsync(config, filter, cancellationToken),
|
||||
IntegrationDiscoveryResourceTypes.Tags => await DiscoverTagsAsync(config, filter, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
@@ -231,6 +252,134 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverRepositoriesAsync(
|
||||
IntegrationConfig config,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await ListProjectsAsync(config, cancellationToken);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
var repositories = new List<DiscoveredIntegrationResource>();
|
||||
|
||||
foreach (var project in projects.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var projectRepositories = await ListRepositoriesAsync(config, project, cancellationToken);
|
||||
repositories.AddRange(projectRepositories
|
||||
.Where(repository => IntegrationDiscoveryFilter.MatchesNamePattern(repository.Name, namePattern))
|
||||
.Select(repository => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
repository.Name,
|
||||
repository.Name,
|
||||
Parent: repository.Project,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["project"] = repository.Project
|
||||
})));
|
||||
}
|
||||
|
||||
return repositories
|
||||
.OrderBy(resource => resource.Parent ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(resource => resource.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverTagsAsync(
|
||||
IntegrationConfig config,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var repositoryPath = IntegrationDiscoveryFilter.GetValue(filter, "repository");
|
||||
if (string.IsNullOrWhiteSpace(repositoryPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var split = repositoryPath.Split('/', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var artifacts = await ListArtifactsAsync(config, split[0], split[1], cancellationToken);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
|
||||
return artifacts
|
||||
.SelectMany(artifact => artifact.Tags.Select(tag => new { artifact.Digest, Tag = tag }))
|
||||
.Where(item => IntegrationDiscoveryFilter.MatchesNamePattern(item.Tag, namePattern))
|
||||
.OrderBy(item => item.Tag, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Tags,
|
||||
$"{repositoryPath}:{item.Tag}",
|
||||
item.Tag,
|
||||
Parent: repositoryPath,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["digest"] = item.Digest,
|
||||
["project"] = split[0],
|
||||
["repository"] = repositoryPath
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<string>> ListProjectsAsync(IntegrationConfig config, CancellationToken ct)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync("/api/v2.0/projects?page=1&page_size=100", ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var projects = JsonSerializer.Deserialize<List<HarborProjectDto>>(content, JsonOptions);
|
||||
|
||||
return (projects ?? [])
|
||||
.Select(project => project.Name)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<RepositoryInfo>> ListRepositoriesAsync(IntegrationConfig config, string project, CancellationToken ct)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync($"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories?page=1&page_size=100", ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(ct);
|
||||
var repositories = JsonSerializer.Deserialize<List<HarborRepositoryDto>>(content, JsonOptions);
|
||||
|
||||
return (repositories ?? [])
|
||||
.Select(repository => new RepositoryInfo
|
||||
{
|
||||
Name = repository.Name ?? string.Empty,
|
||||
Project = project,
|
||||
Tags = []
|
||||
})
|
||||
.Where(repository => !string.IsNullOrWhiteSpace(repository.Name))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -257,14 +406,29 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
private sealed class HarborSearchRepository
|
||||
{
|
||||
[JsonPropertyName("repository_name")]
|
||||
public string? RepositoryName { get; set; }
|
||||
|
||||
[JsonPropertyName("project_name")]
|
||||
public string? ProjectName { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborProjectDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborRepositoryDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborArtifactDto
|
||||
{
|
||||
public string? Digest { get; set; }
|
||||
public List<HarborTagDto>? Tags { get; set; }
|
||||
|
||||
[JsonPropertyName("push_time")]
|
||||
public DateTimeOffset? PushTime { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace StellaOps.Integrations.Plugin.Jenkins;
|
||||
/// Jenkins CI/CD connector plugin.
|
||||
/// Supports Jenkins REST API.
|
||||
/// </summary>
|
||||
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -32,6 +32,12 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Jobs,
|
||||
IntegrationDiscoveryResourceTypes.Pipelines
|
||||
];
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
@@ -141,6 +147,56 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var client = CreateHttpClient(config);
|
||||
var response = await client.GetAsync("/api/json?tree=jobs[name,url,color]", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var info = JsonSerializer.Deserialize<JenkinsJobListResponse>(content, JsonOptions);
|
||||
var namePattern = IntegrationDiscoveryFilter.GetValue(filter, "namePattern");
|
||||
|
||||
return IntegrationDiscoveryResourceTypes.Normalize(resourceType) switch
|
||||
{
|
||||
IntegrationDiscoveryResourceTypes.Jobs => (info?.Jobs ?? [])
|
||||
.Where(job => !string.IsNullOrWhiteSpace(job.Name))
|
||||
.Where(job => IntegrationDiscoveryFilter.MatchesNamePattern(job.Name!, namePattern))
|
||||
.OrderBy(job => job.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(job => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Jobs,
|
||||
job.Name!,
|
||||
job.Name!,
|
||||
Parent: null,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["color"] = job.Color ?? string.Empty,
|
||||
["url"] = job.Url ?? string.Empty
|
||||
}))
|
||||
.ToList(),
|
||||
IntegrationDiscoveryResourceTypes.Pipelines => (info?.Jobs ?? [])
|
||||
.Where(job => !string.IsNullOrWhiteSpace(job.Name))
|
||||
.Where(job => IntegrationDiscoveryFilter.MatchesNamePattern(job.Name!, namePattern))
|
||||
.OrderBy(job => job.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(job => new DiscoveredIntegrationResource(
|
||||
IntegrationDiscoveryResourceTypes.Pipelines,
|
||||
job.Name!,
|
||||
job.Name!,
|
||||
Parent: null,
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["color"] = job.Color ?? string.Empty,
|
||||
["url"] = job.Url ?? string.Empty
|
||||
}))
|
||||
.ToList(),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
@@ -174,4 +230,16 @@ public sealed class JenkinsConnectorPlugin : IIntegrationConnectorPlugin
|
||||
public string? NodeDescription { get; set; }
|
||||
public int NumExecutors { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JenkinsJobListResponse
|
||||
{
|
||||
public List<JenkinsJobDto>? Jobs { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JenkinsJobDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Plugin.DockerRegistry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Tests;
|
||||
|
||||
public sealed class DockerRegistryConnectorPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Repositories_UsesCatalogRouteAndAppliesNamePattern()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/v2/_catalog" => HttpResponse.Json("""{"repositories":["team/api","alpha/app","team/worker"]}"""),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new DockerRegistryConnectorPlugin();
|
||||
var resources = await plugin.DiscoverAsync(
|
||||
CreateConfig(fixture.BaseUrl),
|
||||
IntegrationDiscoveryResourceTypes.Repositories,
|
||||
new Dictionary<string, string> { ["namePattern"] = "team/*" });
|
||||
|
||||
var requestedPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.Equal("/v2/_catalog", requestedPath);
|
||||
Assert.Equal(["team/api", "team/worker"], resources.Select(resource => resource.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Tags_UsesRepositoryRouteWithoutEncodingSlashes()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/v2/team/api/tags/list" => HttpResponse.Json("""{"name":"team/api","tags":["latest","1.0.0"]}"""),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new DockerRegistryConnectorPlugin();
|
||||
var resources = await plugin.DiscoverAsync(
|
||||
CreateConfig(fixture.BaseUrl),
|
||||
IntegrationDiscoveryResourceTypes.Tags,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["repository"] = "team/api"
|
||||
});
|
||||
|
||||
var requestedPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.Equal("/v2/team/api/tags/list", requestedPath);
|
||||
Assert.Equal(["1.0.0", "latest"], resources.Select(resource => resource.Name).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithUsernamePasswordSecret_UsesBasicAuthentication()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/v2/" => HttpResponse.Json("{}"),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new DockerRegistryConnectorPlugin();
|
||||
var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl, "gitlab-ci-token:secret"));
|
||||
|
||||
var request = await fixture.WaitForRequestAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("/v2/", request.Path);
|
||||
Assert.Equal(
|
||||
$"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("gitlab-ci-token:secret"))}",
|
||||
request.Authorization);
|
||||
}
|
||||
|
||||
private static IntegrationConfig CreateConfig(string endpoint, string? resolvedSecret = null)
|
||||
{
|
||||
return new IntegrationConfig(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
Type: IntegrationType.Registry,
|
||||
Provider: IntegrationProvider.DockerHub,
|
||||
Endpoint: endpoint,
|
||||
ResolvedSecret: resolvedSecret,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null);
|
||||
}
|
||||
|
||||
private sealed class LoopbackHttpFixture : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly Task<HttpRequestData> _requestTask;
|
||||
|
||||
private LoopbackHttpFixture(Func<string, HttpResponse> responder)
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
|
||||
_requestTask = HandleSingleRequestAsync(responder);
|
||||
}
|
||||
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public static LoopbackHttpFixture Start(Func<string, HttpResponse> responder) => new(responder);
|
||||
|
||||
public async Task<string> WaitForPathAsync() => (await _requestTask).Path;
|
||||
|
||||
public Task<HttpRequestData> WaitForRequestAsync() => _requestTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpRequestData> HandleSingleRequestAsync(Func<string, HttpResponse> responder)
|
||||
{
|
||||
using var client = await _listener.AcceptTcpClientAsync();
|
||||
using var stream = client.GetStream();
|
||||
using var reader = new StreamReader(
|
||||
stream,
|
||||
Encoding.ASCII,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
bufferSize: 1024,
|
||||
leaveOpen: true);
|
||||
|
||||
var requestLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrWhiteSpace(requestLine))
|
||||
{
|
||||
throw new InvalidOperationException("Did not receive an HTTP request line.");
|
||||
}
|
||||
|
||||
var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (requestParts.Length < 2)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}");
|
||||
}
|
||||
|
||||
string? authorization = null;
|
||||
while (true)
|
||||
{
|
||||
var headerLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrEmpty(headerLine))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (headerLine.StartsWith("Authorization:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
authorization = headerLine["Authorization:".Length..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
var requestPath = requestParts[1];
|
||||
var response = responder(requestPath);
|
||||
var payload = Encoding.UTF8.GetBytes(response.Body);
|
||||
var responseText =
|
||||
$"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" +
|
||||
$"Content-Type: {response.ContentType}\r\n" +
|
||||
$"Content-Length: {payload.Length}\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"\r\n";
|
||||
|
||||
var headerBytes = Encoding.ASCII.GetBytes(responseText);
|
||||
await stream.WriteAsync(headerBytes);
|
||||
await stream.WriteAsync(payload);
|
||||
await stream.FlushAsync();
|
||||
return new HttpRequestData(requestPath, authorization);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HttpRequestData(string Path, string? Authorization);
|
||||
|
||||
private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body)
|
||||
{
|
||||
public static HttpResponse Json(string body) => new(200, "OK", "application/json", body);
|
||||
|
||||
public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.WebService;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Tests;
|
||||
|
||||
public sealed class S3CompatibleConnectorPluginTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithRootEndpoint_UsesMinioHealthProbe()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/minio/health/live" => HttpResponse.Text("OK"),
|
||||
_ => HttpResponse.NotFound(),
|
||||
});
|
||||
|
||||
var plugin = new S3CompatibleConnectorPlugin();
|
||||
var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl));
|
||||
|
||||
var requestPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("/minio/health/live", requestPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithUnhealthyProbe_ReturnsUnhealthy()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/custom/health" => HttpResponse.Unhealthy(),
|
||||
_ => HttpResponse.NotFound(),
|
||||
});
|
||||
|
||||
var plugin = new S3CompatibleConnectorPlugin();
|
||||
var result = await plugin.CheckHealthAsync(CreateConfig($"{fixture.BaseUrl}/custom/health"));
|
||||
|
||||
var requestPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.Equal("/custom/health", requestPath);
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
private static IntegrationConfig CreateConfig(string endpoint)
|
||||
{
|
||||
return new IntegrationConfig(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
Type: IntegrationType.ObjectStorage,
|
||||
Provider: IntegrationProvider.S3Compatible,
|
||||
Endpoint: endpoint,
|
||||
ResolvedSecret: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null);
|
||||
}
|
||||
|
||||
private sealed class LoopbackHttpFixture : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly Task<string> _requestTask;
|
||||
|
||||
private LoopbackHttpFixture(Func<string, HttpResponse> responder)
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
|
||||
_requestTask = HandleSingleRequestAsync(responder);
|
||||
}
|
||||
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public static LoopbackHttpFixture Start(Func<string, HttpResponse> responder) => new(responder);
|
||||
|
||||
public Task<string> WaitForPathAsync() => _requestTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> HandleSingleRequestAsync(Func<string, HttpResponse> responder)
|
||||
{
|
||||
using var client = await _listener.AcceptTcpClientAsync();
|
||||
using var stream = client.GetStream();
|
||||
using var reader = new StreamReader(
|
||||
stream,
|
||||
Encoding.ASCII,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
bufferSize: 1024,
|
||||
leaveOpen: true);
|
||||
|
||||
var requestLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrWhiteSpace(requestLine))
|
||||
{
|
||||
throw new InvalidOperationException("Did not receive an HTTP request line.");
|
||||
}
|
||||
|
||||
var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (requestParts.Length < 2)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}");
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var headerLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrEmpty(headerLine))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var requestPath = requestParts[1];
|
||||
var response = responder(requestPath);
|
||||
var payload = Encoding.UTF8.GetBytes(response.Body);
|
||||
var responseText =
|
||||
$"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" +
|
||||
$"Content-Type: {response.ContentType}\r\n" +
|
||||
$"Content-Length: {payload.Length}\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"\r\n";
|
||||
|
||||
var headerBytes = Encoding.ASCII.GetBytes(responseText);
|
||||
await stream.WriteAsync(headerBytes);
|
||||
await stream.WriteAsync(payload);
|
||||
await stream.FlushAsync();
|
||||
return requestPath;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body)
|
||||
{
|
||||
public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body);
|
||||
|
||||
public static HttpResponse Unhealthy() => new(503, "Service Unavailable", "text/plain", "not-ready");
|
||||
|
||||
public static HttpResponse NotFound() => new(404, "Not Found", "text/plain", "missing");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Integrations.WebService\StellaOps.Integrations.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.DockerRegistry\StellaOps.Integrations.Plugin.DockerRegistry.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||
|
||||
@@ -128,6 +128,88 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationI
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ProvidersEndpoint_HidesTestOnlyProviderByDefault()
|
||||
{
|
||||
var providers = await _client.GetFromJsonAsync<List<ProviderInfo>>(
|
||||
"/api/v1/integrations/providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(providers);
|
||||
Assert.DoesNotContain(providers!, provider => provider.Provider == IntegrationProvider.InMemory);
|
||||
Assert.Contains(providers!, provider => provider.Provider == IntegrationProvider.Custom && provider.SupportsDiscovery);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ProvidersEndpoint_WithIncludeTestOnly_ReturnsInMemory()
|
||||
{
|
||||
var providers = await _client.GetFromJsonAsync<List<ProviderInfo>>(
|
||||
"/api/v1/integrations/providers?includeTestOnly=true",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(providers);
|
||||
Assert.Contains(providers!, provider => provider.Provider == IntegrationProvider.InMemory && provider.IsTestOnly);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task DiscoverEndpoint_WithUnsupportedProvider_ReturnsBadRequest()
|
||||
{
|
||||
var createRequest = new CreateIntegrationRequest(
|
||||
Name: $"Test InMemory {Guid.NewGuid():N}",
|
||||
Description: "Test only connector",
|
||||
Type: IntegrationType.Registry,
|
||||
Provider: IntegrationProvider.InMemory,
|
||||
Endpoint: "http://inmemory.local",
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: ["qa"]);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/v1/integrations/", createRequest, TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
|
||||
|
||||
var discoverResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/integrations/{created!.Id}/discover",
|
||||
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, discoverResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task DiscoverEndpoint_WithDiscoveryProvider_ReturnsOrderedResources()
|
||||
{
|
||||
var createRequest = new CreateIntegrationRequest(
|
||||
Name: $"Custom SCM {Guid.NewGuid():N}",
|
||||
Description: "Discovery connector",
|
||||
Type: IntegrationType.Scm,
|
||||
Provider: IntegrationProvider.Custom,
|
||||
Endpoint: "https://custom.example.local",
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: ["qa"]);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/v1/integrations/", createRequest, TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
|
||||
|
||||
var discoverResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/integrations/{created!.Id}/discover",
|
||||
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
|
||||
TestContext.Current.CancellationToken);
|
||||
discoverResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var discovered = await discoverResponse.Content.ReadFromJsonAsync<DiscoverIntegrationResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(discovered);
|
||||
Assert.Equal(["alpha/service", "zeta/service"], discovered!.Resources.Select(resource => resource.Name).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFactory<Program>
|
||||
@@ -150,6 +232,15 @@ public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFacto
|
||||
services.RemoveAll<IIntegrationRepository>();
|
||||
services.AddSingleton<IIntegrationRepository, InMemoryIntegrationRepository>();
|
||||
|
||||
services.RemoveAll<IntegrationPluginLoader>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var loader = new IntegrationPluginLoader(sp.GetRequiredService<ILogger<IntegrationPluginLoader>>());
|
||||
loader.Register(new FakeInMemoryConnectorPlugin());
|
||||
loader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
return loader;
|
||||
});
|
||||
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
|
||||
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
|
||||
@@ -394,3 +485,65 @@ internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeInMemoryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string Name => "fake-inmemory";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.InMemory;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeDiscoveryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
public string Name => "fake-discovery";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Custom;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Repositories
|
||||
];
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<DiscoveredIntegrationResource> resources =
|
||||
[
|
||||
new(resourceType, "zeta/service", "zeta/service", "zeta", null),
|
||||
new(resourceType, "alpha/service", "alpha/service", "alpha", null)
|
||||
];
|
||||
|
||||
return Task.FromResult(resources);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,96 @@ public sealed class IntegrationServiceTests
|
||||
_service.GetSupportedProviders().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetSupportedProviders_WithDiscoveryPlugin_HidesTestOnlyByDefault()
|
||||
{
|
||||
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
_pluginLoader.Register(new FakeTestOnlyConnectorPlugin());
|
||||
|
||||
var providers = _service.GetSupportedProviders();
|
||||
|
||||
providers.Should().ContainSingle(provider => provider.Provider == IntegrationProvider.Custom);
|
||||
providers.Should().NotContain(provider => provider.Provider == IntegrationProvider.InMemory);
|
||||
providers.Single().SupportsDiscovery.Should().BeTrue();
|
||||
providers.Single().SupportedResourceTypes.Should().Equal(
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
IntegrationDiscoveryResourceTypes.Repositories);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetSupportedProviders_WithIncludeTestOnly_IncludesInMemory()
|
||||
{
|
||||
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
_pluginLoader.Register(new FakeTestOnlyConnectorPlugin());
|
||||
|
||||
var providers = _service.GetSupportedProviders(includeTestOnly: true);
|
||||
|
||||
providers.Should().Contain(provider => provider.Provider == IntegrationProvider.InMemory && provider.IsTestOnly);
|
||||
providers.Should().Contain(provider => provider.Provider == IntegrationProvider.Custom && provider.SupportsDiscovery);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_WithUnsupportedResourceType_ReturnsUnsupportedAndSupportedTypes()
|
||||
{
|
||||
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom);
|
||||
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
var result = await _service.DiscoverAsync(
|
||||
integration.Id,
|
||||
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Tags, null),
|
||||
"tenant-1");
|
||||
|
||||
result.Status.Should().Be(DiscoveryExecutionStatus.Unsupported);
|
||||
result.Response.Should().BeNull();
|
||||
result.SupportedResourceTypes.Should().Equal(
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
IntegrationDiscoveryResourceTypes.Repositories);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_WithDiscoveryPlugin_ReturnsSortedResources()
|
||||
{
|
||||
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom);
|
||||
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
var result = await _service.DiscoverAsync(
|
||||
integration.Id,
|
||||
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
|
||||
"tenant-1");
|
||||
|
||||
result.Status.Should().Be(DiscoveryExecutionStatus.Success);
|
||||
result.Response.Should().NotBeNull();
|
||||
result.Response!.Resources.Select(resource => resource.Name).Should().Equal("alpha/service", "zeta/service");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_WithTenantMismatch_ReturnsIntegrationNotFound()
|
||||
{
|
||||
var integration = CreateTestIntegration(provider: IntegrationProvider.Custom, tenantId: "tenant-a");
|
||||
_pluginLoader.Register(new FakeDiscoveryConnectorPlugin());
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
var result = await _service.DiscoverAsync(
|
||||
integration.Id,
|
||||
new DiscoverIntegrationRequest(IntegrationDiscoveryResourceTypes.Repositories, null),
|
||||
"tenant-b");
|
||||
|
||||
result.Status.Should().Be(DiscoveryExecutionStatus.IntegrationNotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithTenantMismatch_ReturnsNull()
|
||||
@@ -356,4 +446,67 @@ public sealed class IntegrationServiceTests
|
||||
UpdatedAt = now,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeDiscoveryConnectorPlugin : IIntegrationConnectorPlugin, IIntegrationDiscoveryPlugin
|
||||
{
|
||||
public string Name => "fake-discovery";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Custom;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public IReadOnlyList<string> SupportedResourceTypes =>
|
||||
[
|
||||
IntegrationDiscoveryResourceTypes.Projects,
|
||||
IntegrationDiscoveryResourceTypes.Repositories
|
||||
];
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredIntegrationResource>> DiscoverAsync(
|
||||
IntegrationConfig config,
|
||||
string resourceType,
|
||||
IReadOnlyDictionary<string, string>? filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<DiscoveredIntegrationResource> resources =
|
||||
[
|
||||
new DiscoveredIntegrationResource(resourceType, "zeta/service", "zeta/service", "zeta", null),
|
||||
new DiscoveredIntegrationResource(resourceType, "alpha/service", "alpha/service", "alpha", null)
|
||||
];
|
||||
|
||||
return Task.FromResult(resources);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTestOnlyConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string Name => "fake-inmemory";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.InMemory;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(true, "ok", null, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, "ok", null, DateTimeOffset.UtcNow, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user