Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes integration lifecycle events to downstream consumers.
|
||||
/// </summary>
|
||||
public interface IIntegrationEventPublisher
|
||||
{
|
||||
Task PublishAsync(IntegrationEvent @event, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs integration audit events.
|
||||
/// </summary>
|
||||
public interface IIntegrationAuditLogger
|
||||
{
|
||||
Task LogAsync(string action, Guid integrationId, string? userId, object? details, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves AuthRef URIs to secret values.
|
||||
/// </summary>
|
||||
public interface IAuthRefResolver
|
||||
{
|
||||
Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.WebService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Console/log-based event publisher for development and standalone deployments.
|
||||
/// In production, replace with message queue implementation.
|
||||
/// </summary>
|
||||
public sealed class LoggingEventPublisher : IIntegrationEventPublisher
|
||||
{
|
||||
private readonly ILogger<LoggingEventPublisher> _logger;
|
||||
|
||||
public LoggingEventPublisher(ILogger<LoggingEventPublisher> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task PublishAsync(IntegrationEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Integration event: {EventType} - {EventJson}",
|
||||
@event.GetType().Name,
|
||||
JsonSerializer.Serialize(@event, @event.GetType()));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Console/log-based audit logger for development and standalone deployments.
|
||||
/// In production, replace with proper audit store.
|
||||
/// </summary>
|
||||
public sealed class LoggingAuditLogger : IIntegrationAuditLogger
|
||||
{
|
||||
private readonly ILogger<LoggingAuditLogger> _logger;
|
||||
|
||||
public LoggingAuditLogger(ILogger<LoggingAuditLogger> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task LogAsync(string action, Guid integrationId, string? userId, object? details, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Audit: {Action} on {IntegrationId} by {UserId} - {Details}",
|
||||
action,
|
||||
integrationId,
|
||||
userId ?? "system",
|
||||
details is not null ? JsonSerializer.Serialize(details) : "{}");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub AuthRef resolver for development.
|
||||
/// In production, integrate with Authority service.
|
||||
/// </summary>
|
||||
public sealed class StubAuthRefResolver : IAuthRefResolver
|
||||
{
|
||||
private readonly ILogger<StubAuthRefResolver> _logger;
|
||||
|
||||
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning("StubAuthRefResolver: Would resolve {AuthRefUri} - returning null in dev mode", authRefUri);
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for the Integration Catalog.
|
||||
/// </summary>
|
||||
public static class IntegrationEndpoints
|
||||
{
|
||||
public static void MapIntegrationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/integrations")
|
||||
.WithTags("Integrations")
|
||||
.WithOpenApi();
|
||||
|
||||
// List integrations
|
||||
group.MapGet("/", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromQuery] IntegrationType? type,
|
||||
[FromQuery] IntegrationProvider? provider,
|
||||
[FromQuery] IntegrationStatus? status,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string sortBy = "name",
|
||||
[FromQuery] bool sortDescending = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
{
|
||||
var query = new ListIntegrationsQuery(type, provider, status, search, null, page, pageSize, sortBy, sortDescending);
|
||||
var result = await service.ListAsync(query, null, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("ListIntegrations")
|
||||
.WithDescription("Lists integrations with optional filtering and pagination.");
|
||||
|
||||
// Get integration by ID
|
||||
group.MapGet("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.GetByIdAsync(id, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("GetIntegration")
|
||||
.WithDescription("Gets an integration by ID.");
|
||||
|
||||
// Create integration
|
||||
group.MapPost("/", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromBody] CreateIntegrationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.CreateAsync(request, null, null, cancellationToken);
|
||||
return Results.Created($"/api/v1/integrations/{result.Id}", result);
|
||||
})
|
||||
.WithName("CreateIntegration")
|
||||
.WithDescription("Creates a new integration.");
|
||||
|
||||
// Update integration
|
||||
group.MapPut("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
[FromBody] UpdateIntegrationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, null, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("UpdateIntegration")
|
||||
.WithDescription("Updates an existing integration.");
|
||||
|
||||
// Delete integration
|
||||
group.MapDelete("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.DeleteAsync(id, null, cancellationToken);
|
||||
return result ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("DeleteIntegration")
|
||||
.WithDescription("Soft-deletes an integration.");
|
||||
|
||||
// Test connection
|
||||
group.MapPost("/{id:guid}/test", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.TestConnectionAsync(id, null, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("TestIntegrationConnection")
|
||||
.WithDescription("Tests connectivity and authentication for an integration.");
|
||||
|
||||
// Health check
|
||||
group.MapGet("/{id:guid}/health", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.CheckHealthAsync(id, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("CheckIntegrationHealth")
|
||||
.WithDescription("Performs a health check on an integration.");
|
||||
|
||||
// Get supported providers
|
||||
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
|
||||
{
|
||||
var result = service.GetSupportedProviders();
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetSupportedProviders")
|
||||
.WithDescription("Gets a list of supported integration providers.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Plugin;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and manages integration connector plugins.
|
||||
/// </summary>
|
||||
public sealed class IntegrationPluginLoader
|
||||
{
|
||||
private readonly ILogger<IntegrationPluginLoader>? _logger;
|
||||
private readonly List<IIntegrationConnectorPlugin> _plugins = [];
|
||||
|
||||
public IntegrationPluginLoader(ILogger<IntegrationPluginLoader>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all loaded plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> Plugins => _plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and loads integration connector plugins from the specified directory.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromDirectory(
|
||||
string pluginDirectory,
|
||||
string searchPattern = "StellaOps.Integrations.Plugin.*.dll")
|
||||
{
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
_logger?.LogWarning("Plugin directory does not exist: {Directory}", pluginDirectory);
|
||||
return [];
|
||||
}
|
||||
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
PluginsDirectory = pluginDirectory,
|
||||
EnsureDirectoryExists = false,
|
||||
RecursiveSearch = false
|
||||
};
|
||||
options.SearchPatterns.Add(searchPattern);
|
||||
|
||||
var result = PluginHost.LoadPlugins(options);
|
||||
var loadedPlugins = new List<IIntegrationConnectorPlugin>();
|
||||
|
||||
foreach (var pluginAssembly in result.Plugins)
|
||||
{
|
||||
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(new[] { pluginAssembly.Assembly });
|
||||
loadedPlugins.AddRange(connectorPlugins);
|
||||
|
||||
foreach (var plugin in connectorPlugins)
|
||||
{
|
||||
_logger?.LogDebug("Loaded integration connector plugin: {Name} ({Provider}) from {Assembly}",
|
||||
plugin.Name, plugin.Provider, pluginAssembly.Assembly.GetName().Name);
|
||||
}
|
||||
}
|
||||
|
||||
_plugins.AddRange(loadedPlugins);
|
||||
return loadedPlugins;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads integration connector plugins from the specified assemblies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
|
||||
{
|
||||
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies);
|
||||
_plugins.AddRange(loadedPlugins);
|
||||
return loadedPlugins;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a plugin by provider.
|
||||
/// </summary>
|
||||
public IIntegrationConnectorPlugin? GetByProvider(IntegrationProvider provider)
|
||||
{
|
||||
return _plugins.FirstOrDefault(p => p.Provider == provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all plugins for a given type.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> GetByType(IntegrationType type)
|
||||
{
|
||||
return _plugins.Where(p => p.Type == type).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available plugins (checking IsAvailable).
|
||||
/// </summary>
|
||||
public IReadOnlyList<IIntegrationConnectorPlugin> GetAvailable(IServiceProvider services)
|
||||
{
|
||||
return _plugins.Where(p =>
|
||||
{
|
||||
try { return p.IsAvailable(services); }
|
||||
catch { return false; }
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
|
||||
namespace StellaOps.Integrations.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Core service for integration catalog operations.
|
||||
/// </summary>
|
||||
public sealed class IntegrationService
|
||||
{
|
||||
private readonly IIntegrationRepository _repository;
|
||||
private readonly IntegrationPluginLoader _pluginLoader;
|
||||
private readonly IIntegrationEventPublisher _eventPublisher;
|
||||
private readonly IIntegrationAuditLogger _auditLogger;
|
||||
private readonly IAuthRefResolver _authRefResolver;
|
||||
private readonly ILogger<IntegrationService> _logger;
|
||||
|
||||
public IntegrationService(
|
||||
IIntegrationRepository repository,
|
||||
IntegrationPluginLoader pluginLoader,
|
||||
IIntegrationEventPublisher eventPublisher,
|
||||
IIntegrationAuditLogger auditLogger,
|
||||
IAuthRefResolver authRefResolver,
|
||||
ILogger<IntegrationService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_pluginLoader = pluginLoader;
|
||||
_eventPublisher = eventPublisher;
|
||||
_auditLogger = auditLogger;
|
||||
_authRefResolver = authRefResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = new Integration
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Type = request.Type,
|
||||
Provider = request.Provider,
|
||||
Status = IntegrationStatus.Pending,
|
||||
Endpoint = request.Endpoint,
|
||||
AuthRefUri = request.AuthRefUri,
|
||||
OrganizationId = request.OrganizationId,
|
||||
ConfigJson = request.ExtendedConfig is not null ? JsonSerializer.Serialize(request.ExtendedConfig) : null,
|
||||
Tags = request.Tags?.ToList() ?? [],
|
||||
CreatedBy = userId,
|
||||
UpdatedBy = userId,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
var created = await _repository.CreateAsync(integration, cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationCreatedEvent(
|
||||
created.Id,
|
||||
created.Name,
|
||||
created.Type,
|
||||
created.Provider,
|
||||
userId,
|
||||
DateTimeOffset.UtcNow), cancellationToken);
|
||||
|
||||
await _auditLogger.LogAsync("integration.created", created.Id, userId, new { created.Name, created.Type, created.Provider }, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
|
||||
|
||||
return MapToResponse(created);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
return integration is null ? null : MapToResponse(integration);
|
||||
}
|
||||
|
||||
public async Task<PagedIntegrationsResponse> ListAsync(ListIntegrationsQuery query, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoQuery = new IntegrationQuery(
|
||||
Type: query.Type,
|
||||
Provider: query.Provider,
|
||||
Status: query.Status,
|
||||
Search: query.Search,
|
||||
Tags: query.Tags,
|
||||
TenantId: tenantId,
|
||||
Skip: (query.Page - 1) * query.PageSize,
|
||||
Take: query.PageSize,
|
||||
SortBy: query.SortBy,
|
||||
SortDescending: query.SortDescending);
|
||||
|
||||
var integrations = await _repository.GetAllAsync(repoQuery, cancellationToken);
|
||||
var totalCount = await _repository.CountAsync(repoQuery, cancellationToken);
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize);
|
||||
|
||||
return new PagedIntegrationsResponse(
|
||||
integrations.Select(MapToResponse).ToList(),
|
||||
totalCount,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
totalPages);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse?> UpdateAsync(Guid id, UpdateIntegrationRequest request, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var oldStatus = integration.Status;
|
||||
|
||||
if (request.Name is not null) integration.Name = request.Name;
|
||||
if (request.Description is not null) integration.Description = request.Description;
|
||||
if (request.Endpoint is not null) integration.Endpoint = request.Endpoint;
|
||||
if (request.AuthRefUri is not null) integration.AuthRefUri = request.AuthRefUri;
|
||||
if (request.OrganizationId is not null) integration.OrganizationId = request.OrganizationId;
|
||||
if (request.ExtendedConfig is not null) integration.ConfigJson = JsonSerializer.Serialize(request.ExtendedConfig);
|
||||
if (request.Tags is not null) integration.Tags = request.Tags.ToList();
|
||||
if (request.Status.HasValue) integration.Status = request.Status.Value;
|
||||
|
||||
integration.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
integration.UpdatedBy = userId;
|
||||
|
||||
var updated = await _repository.UpdateAsync(integration, cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationUpdatedEvent(
|
||||
updated.Id,
|
||||
updated.Name,
|
||||
userId,
|
||||
DateTimeOffset.UtcNow), cancellationToken);
|
||||
|
||||
if (oldStatus != updated.Status)
|
||||
{
|
||||
await _eventPublisher.PublishAsync(new IntegrationStatusChangedEvent(
|
||||
updated.Id,
|
||||
oldStatus,
|
||||
updated.Status,
|
||||
DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
|
||||
await _auditLogger.LogAsync("integration.updated", updated.Id, userId, new { updated.Name, OldStatus = oldStatus, NewStatus = updated.Status }, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Integration updated: {Id} ({Name}) by {User}", updated.Id, updated.Name, userId);
|
||||
|
||||
return MapToResponse(updated);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null) return false;
|
||||
|
||||
await _repository.DeleteAsync(id, cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationDeletedEvent(
|
||||
id,
|
||||
userId,
|
||||
DateTimeOffset.UtcNow), cancellationToken);
|
||||
|
||||
await _auditLogger.LogAsync("integration.deleted", id, userId, new { integration.Name }, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Integration deleted: {Id} ({Name}) by {User}", id, integration.Name, userId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResponse?> TestConnectionAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var plugin = _pluginLoader.GetByProvider(integration.Provider);
|
||||
if (plugin is null)
|
||||
{
|
||||
_logger.LogWarning("No plugin found for provider {Provider}", integration.Provider);
|
||||
return new TestConnectionResponse(
|
||||
id,
|
||||
false,
|
||||
$"No connector plugin available for provider {integration.Provider}",
|
||||
null,
|
||||
TimeSpan.Zero,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var resolvedSecret = integration.AuthRefUri is not null
|
||||
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
|
||||
: null;
|
||||
|
||||
var config = BuildConfig(integration, resolvedSecret);
|
||||
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var result = await plugin.TestConnectionAsync(config, cancellationToken);
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Update integration status based on result
|
||||
var newStatus = result.Success ? IntegrationStatus.Active : IntegrationStatus.Failed;
|
||||
if (integration.Status != newStatus)
|
||||
{
|
||||
var oldStatus = integration.Status;
|
||||
integration.Status = newStatus;
|
||||
integration.UpdatedAt = endTime;
|
||||
await _repository.UpdateAsync(integration, cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationStatusChangedEvent(
|
||||
id, oldStatus, newStatus, endTime), cancellationToken);
|
||||
}
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationTestConnectionEvent(
|
||||
id, result.Success, result.Message, endTime), cancellationToken);
|
||||
|
||||
await _auditLogger.LogAsync("integration.test_connection", id, userId, new { result.Success, result.Message }, cancellationToken);
|
||||
|
||||
return new TestConnectionResponse(
|
||||
id,
|
||||
result.Success,
|
||||
result.Message,
|
||||
result.Details,
|
||||
result.Duration,
|
||||
endTime);
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResponse?> CheckHealthAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var plugin = _pluginLoader.GetByProvider(integration.Provider);
|
||||
if (plugin is null)
|
||||
{
|
||||
return new HealthCheckResponse(
|
||||
id,
|
||||
HealthStatus.Unknown,
|
||||
$"No connector plugin available for provider {integration.Provider}",
|
||||
null,
|
||||
DateTimeOffset.UtcNow,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var resolvedSecret = integration.AuthRefUri is not null
|
||||
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
|
||||
: null;
|
||||
|
||||
var config = BuildConfig(integration, resolvedSecret);
|
||||
var result = await plugin.CheckHealthAsync(config, cancellationToken);
|
||||
|
||||
var oldHealth = integration.LastHealthStatus;
|
||||
if (oldHealth != result.Status)
|
||||
{
|
||||
await _repository.UpdateHealthStatusAsync(id, result.Status, result.CheckedAt, cancellationToken);
|
||||
|
||||
await _eventPublisher.PublishAsync(new IntegrationHealthChangedEvent(
|
||||
id, oldHealth, result.Status, result.CheckedAt), cancellationToken);
|
||||
}
|
||||
|
||||
return new HealthCheckResponse(
|
||||
id,
|
||||
result.Status,
|
||||
result.Message,
|
||||
result.Details,
|
||||
result.CheckedAt,
|
||||
result.Duration);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
|
||||
{
|
||||
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
|
||||
p.Name,
|
||||
p.Type,
|
||||
p.Provider)).ToList();
|
||||
}
|
||||
|
||||
private static IntegrationConfig BuildConfig(Integration integration, string? resolvedSecret)
|
||||
{
|
||||
IReadOnlyDictionary<string, object>? extendedConfig = null;
|
||||
if (!string.IsNullOrEmpty(integration.ConfigJson))
|
||||
{
|
||||
extendedConfig = JsonSerializer.Deserialize<Dictionary<string, object>>(integration.ConfigJson);
|
||||
}
|
||||
|
||||
return new IntegrationConfig(
|
||||
integration.Id,
|
||||
integration.Type,
|
||||
integration.Provider,
|
||||
integration.Endpoint,
|
||||
resolvedSecret,
|
||||
integration.OrganizationId,
|
||||
extendedConfig);
|
||||
}
|
||||
|
||||
private static IntegrationResponse MapToResponse(Integration integration)
|
||||
{
|
||||
return new IntegrationResponse(
|
||||
integration.Id,
|
||||
integration.Name,
|
||||
integration.Description,
|
||||
integration.Type,
|
||||
integration.Provider,
|
||||
integration.Status,
|
||||
integration.Endpoint,
|
||||
integration.AuthRefUri is not null,
|
||||
integration.OrganizationId,
|
||||
integration.LastHealthStatus,
|
||||
integration.LastHealthCheckAt,
|
||||
integration.CreatedAt,
|
||||
integration.UpdatedAt,
|
||||
integration.CreatedBy,
|
||||
integration.UpdatedBy,
|
||||
integration.Tags);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a supported provider.
|
||||
/// </summary>
|
||||
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using StellaOps.Integrations.WebService.Infrastructure;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new() { Title = "StellaOps Integration Catalog API", Version = "v1" });
|
||||
});
|
||||
|
||||
// Database
|
||||
var connectionString = builder.Configuration.GetConnectionString("IntegrationsDb")
|
||||
?? "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres";
|
||||
|
||||
builder.Services.AddDbContext<IntegrationDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// Repository
|
||||
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
|
||||
|
||||
// Plugin loader
|
||||
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<IntegrationPluginLoader>>();
|
||||
var loader = new IntegrationPluginLoader(logger);
|
||||
|
||||
// Load from plugins directory
|
||||
var pluginsDir = builder.Configuration.GetValue<string>("Integrations:PluginsDirectory")
|
||||
?? Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
if (Directory.Exists(pluginsDir))
|
||||
{
|
||||
loader.LoadFromDirectory(pluginsDir);
|
||||
}
|
||||
|
||||
// Also load from current assembly (for built-in plugins)
|
||||
loader.LoadFromAssemblies([typeof(Program).Assembly]);
|
||||
|
||||
return loader;
|
||||
});
|
||||
|
||||
// Infrastructure
|
||||
builder.Services.AddScoped<IIntegrationEventPublisher, LoggingEventPublisher>();
|
||||
builder.Services.AddScoped<IIntegrationAuditLogger, LoggingAuditLogger>();
|
||||
builder.Services.AddScoped<IAuthRefResolver, StubAuthRefResolver>();
|
||||
|
||||
// Core service
|
||||
builder.Services.AddScoped<IntegrationService>();
|
||||
|
||||
// CORS for Angular dev server
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:4200", "https://localhost:4200")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
|
||||
// Map endpoints
|
||||
app.MapIntegrationEndpoints();
|
||||
|
||||
// Health endpoint
|
||||
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }))
|
||||
.WithTags("Health")
|
||||
.WithName("HealthCheck");
|
||||
|
||||
// Ensure database is created (dev only)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<IntegrationDbContext>();
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.WebService</RootNamespace>
|
||||
<AssemblyName>StellaOps.Integrations.WebService</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"IntegrationsDb": "Host=localhost;Database=stellaops_integrations_dev;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Integrations": {
|
||||
"PluginsDirectory": "plugins"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"IntegrationsDb": "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Integrations": {
|
||||
"PluginsDirectory": "plugins"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user