Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
84
src/Integrations/AGENTS.md
Normal file
84
src/Integrations/AGENTS.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Integrations Module – Agent Instructions
|
||||
|
||||
## Module Identity
|
||||
|
||||
**Module:** Integrations
|
||||
**Purpose:** Canonical integration catalog for registries, SCM providers, CI systems, repo sources, and runtime hosts.
|
||||
**Deployable:** `src/Integrations/StellaOps.Integrations.WebService`
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
src/Integrations/
|
||||
├── StellaOps.Integrations.WebService/ # ASP.NET Core host
|
||||
├── __Libraries/
|
||||
│ ├── StellaOps.Integrations.Core/ # Domain models, enums, events
|
||||
│ ├── StellaOps.Integrations.Contracts/ # Plugin contracts and DTOs
|
||||
│ ├── StellaOps.Integrations.Plugins.Abstractions/ # IIntegrationConnectorPlugin
|
||||
│ ├── StellaOps.Integrations.Persistence/ # PostgreSQL repositories
|
||||
│ └── StellaOps.Integrations.Testing/ # Shared test fixtures
|
||||
├── __Plugins/
|
||||
│ ├── StellaOps.Integrations.Plugin.GitHubApp/
|
||||
│ ├── StellaOps.Integrations.Plugin.GitLab/
|
||||
│ ├── StellaOps.Integrations.Plugin.Harbor/
|
||||
│ ├── StellaOps.Integrations.Plugin.Ecr/
|
||||
│ ├── StellaOps.Integrations.Plugin.Gcr/
|
||||
│ ├── StellaOps.Integrations.Plugin.Acr/
|
||||
│ └── StellaOps.Integrations.Plugin.InMemory/ # Testing / dev
|
||||
└── __Tests/
|
||||
└── StellaOps.Integrations.Tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roles & Responsibilities
|
||||
|
||||
| Role | Expectations |
|
||||
| --- | --- |
|
||||
| Backend Engineer | Implement core catalog, plugin loader, API endpoints, event publisher |
|
||||
| Plugin Author | Implement `IIntegrationConnectorPlugin` for new providers |
|
||||
| QA Engineer | Cover plugin loading, test-connection, health polling, CRUD scenarios |
|
||||
| PM/Architect | Keep plugin contracts stable; coordinate cross-module dependencies |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
All connectors implement `IIntegrationConnectorPlugin : IAvailabilityPlugin`:
|
||||
|
||||
```csharp
|
||||
public interface IIntegrationConnectorPlugin : IAvailabilityPlugin
|
||||
{
|
||||
IntegrationType Type { get; }
|
||||
IntegrationProvider Provider { get; }
|
||||
Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken ct);
|
||||
Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Required Reading
|
||||
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/architecture/integrations.md`
|
||||
- `docs/modules/authority/architecture.md` (AuthRef handling)
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **No raw secrets:** All credentials use AuthRef URIs resolved at runtime.
|
||||
2. **Determinism:** Stable ordering in listings; UTC timestamps.
|
||||
3. **Offline-first:** All plugin test-connection and health must handle network failure gracefully.
|
||||
4. **Event emission:** Lifecycle events go to Scheduler/Signals via message queue.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- Unit tests in `__Tests/StellaOps.Integrations.Tests`
|
||||
- Each plugin has its own test class mocking external APIs
|
||||
- Integration tests use `StellaOps.Integrations.Plugin.InMemory`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Integrations.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin contract for integration connectors.
|
||||
/// Each provider (GitHub, Harbor, ECR, etc.) implements this interface.
|
||||
/// </summary>
|
||||
public interface IIntegrationConnectorPlugin : IAvailabilityPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration type this plugin handles.
|
||||
/// </summary>
|
||||
IntegrationType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific provider implementation.
|
||||
/// </summary>
|
||||
IntegrationProvider Provider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tests connectivity and authentication to the integration endpoint.
|
||||
/// </summary>
|
||||
/// <param name="config">Configuration including resolved secrets.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result indicating success or failure with details.</returns>
|
||||
Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a health check on the integration endpoint.
|
||||
/// </summary>
|
||||
/// <param name="config">Configuration including resolved secrets.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Health check result with status and details.</returns>
|
||||
Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for creating an integration.
|
||||
/// </summary>
|
||||
public sealed record CreateIntegrationRequest(
|
||||
string Name,
|
||||
string? Description,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
string Endpoint,
|
||||
string? AuthRefUri,
|
||||
string? OrganizationId,
|
||||
IReadOnlyDictionary<string, object>? ExtendedConfig,
|
||||
IReadOnlyList<string>? Tags);
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for updating an integration.
|
||||
/// </summary>
|
||||
public sealed record UpdateIntegrationRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Endpoint,
|
||||
string? AuthRefUri,
|
||||
string? OrganizationId,
|
||||
IReadOnlyDictionary<string, object>? ExtendedConfig,
|
||||
IReadOnlyList<string>? Tags,
|
||||
IntegrationStatus? Status);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for integration details.
|
||||
/// </summary>
|
||||
public sealed record IntegrationResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
IntegrationStatus Status,
|
||||
string Endpoint,
|
||||
bool HasAuth,
|
||||
string? OrganizationId,
|
||||
HealthStatus LastHealthStatus,
|
||||
DateTimeOffset? LastHealthCheckAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? CreatedBy,
|
||||
string? UpdatedBy,
|
||||
IReadOnlyList<string> Tags);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for test-connection operation.
|
||||
/// </summary>
|
||||
public sealed record TestConnectionResponse(
|
||||
Guid IntegrationId,
|
||||
bool Success,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string>? Details,
|
||||
TimeSpan Duration,
|
||||
DateTimeOffset TestedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for health check operation.
|
||||
/// </summary>
|
||||
public sealed record HealthCheckResponse(
|
||||
Guid IntegrationId,
|
||||
HealthStatus Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string>? Details,
|
||||
DateTimeOffset CheckedAt,
|
||||
TimeSpan Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing integrations.
|
||||
/// </summary>
|
||||
public sealed record ListIntegrationsQuery(
|
||||
IntegrationType? Type = null,
|
||||
IntegrationProvider? Provider = null,
|
||||
IntegrationStatus? Status = null,
|
||||
string? Search = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
string SortBy = "name",
|
||||
bool SortDescending = false);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated response for integration listings.
|
||||
/// </summary>
|
||||
public sealed record PagedIntegrationsResponse(
|
||||
IReadOnlyList<IntegrationResponse> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize,
|
||||
int TotalPages);
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Contracts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace StellaOps.Integrations.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Core domain entity representing a configured integration.
|
||||
/// </summary>
|
||||
public sealed class Integration
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for the integration.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Classification of integration by functional purpose.
|
||||
/// </summary>
|
||||
public required IntegrationType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific provider implementation.
|
||||
/// </summary>
|
||||
public required IntegrationProvider Provider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status.
|
||||
/// </summary>
|
||||
public IntegrationStatus Status { get; set; } = IntegrationStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL or endpoint for the integration.
|
||||
/// </summary>
|
||||
public required string Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to stored credentials (AuthRef URI, never raw secret).
|
||||
/// Format: authref://{vault}/{path}#{key} or similar.
|
||||
/// </summary>
|
||||
public string? AuthRefUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Organization or tenant identifier within the provider.
|
||||
/// </summary>
|
||||
public string? OrganizationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional provider-specific configuration as JSON.
|
||||
/// </summary>
|
||||
public string? ConfigJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last health check result.
|
||||
/// </summary>
|
||||
public HealthStatus LastHealthStatus { get; set; } = HealthStatus.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of last health check.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastHealthCheckAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the integration was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the integration was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// User or system that created this integration.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User or system that last modified this integration.
|
||||
/// </summary>
|
||||
public string? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant/workspace isolation identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for filtering and grouping.
|
||||
/// </summary>
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Soft-delete marker.
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace StellaOps.Integrations.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Classification of integration by functional purpose.
|
||||
/// </summary>
|
||||
public enum IntegrationType
|
||||
{
|
||||
/// <summary>Container registry (Harbor, ECR, GCR, ACR, Docker Hub, etc.).</summary>
|
||||
Registry = 1,
|
||||
|
||||
/// <summary>Source code management (GitHub, GitLab, Bitbucket, Gitea, etc.).</summary>
|
||||
Scm = 2,
|
||||
|
||||
/// <summary>CI/CD system (GitHub Actions, GitLab CI, Jenkins, etc.).</summary>
|
||||
CiCd = 3,
|
||||
|
||||
/// <summary>Repository source for packages (npm, PyPI, Maven, NuGet, etc.).</summary>
|
||||
RepoSource = 4,
|
||||
|
||||
/// <summary>Runtime host for telemetry (eBPF, ETW, dyld, etc.).</summary>
|
||||
RuntimeHost = 5,
|
||||
|
||||
/// <summary>Advisory/vulnerability feed mirror.</summary>
|
||||
FeedMirror = 6
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specific provider implementation within an integration type.
|
||||
/// </summary>
|
||||
public enum IntegrationProvider
|
||||
{
|
||||
// Registry providers
|
||||
Harbor = 100,
|
||||
Ecr = 101,
|
||||
Gcr = 102,
|
||||
Acr = 103,
|
||||
DockerHub = 104,
|
||||
Quay = 105,
|
||||
Artifactory = 106,
|
||||
Nexus = 107,
|
||||
GitHubContainerRegistry = 108,
|
||||
GitLabContainerRegistry = 109,
|
||||
|
||||
// SCM providers
|
||||
GitHubApp = 200,
|
||||
GitLabServer = 201,
|
||||
Bitbucket = 202,
|
||||
Gitea = 203,
|
||||
AzureDevOps = 204,
|
||||
|
||||
// CI/CD providers
|
||||
GitHubActions = 300,
|
||||
GitLabCi = 301,
|
||||
Jenkins = 302,
|
||||
CircleCi = 303,
|
||||
AzurePipelines = 304,
|
||||
ArgoWorkflows = 305,
|
||||
Tekton = 306,
|
||||
|
||||
// Repo sources
|
||||
NpmRegistry = 400,
|
||||
PyPi = 401,
|
||||
MavenCentral = 402,
|
||||
NuGetOrg = 403,
|
||||
CratesIo = 404,
|
||||
GoProxy = 405,
|
||||
|
||||
// Runtime hosts
|
||||
EbpfAgent = 500,
|
||||
EtwAgent = 501,
|
||||
DyldInterposer = 502,
|
||||
|
||||
// Feed mirrors
|
||||
StellaOpsMirror = 600,
|
||||
NvdMirror = 601,
|
||||
OsvMirror = 602,
|
||||
|
||||
// Generic / testing
|
||||
InMemory = 900,
|
||||
Custom = 999
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of an integration instance.
|
||||
/// </summary>
|
||||
public enum IntegrationStatus
|
||||
{
|
||||
/// <summary>Just created, not yet tested.</summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>Connection test passed.</summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>Connection test failed.</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>Administratively disabled.</summary>
|
||||
Disabled = 3,
|
||||
|
||||
/// <summary>Marked for deletion.</summary>
|
||||
Archived = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check result status.
|
||||
/// </summary>
|
||||
public enum HealthStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
Healthy = 1,
|
||||
Degraded = 2,
|
||||
Unhealthy = 3
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Integrations.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration passed to connector plugins for test-connection and health checks.
|
||||
/// </summary>
|
||||
public sealed record IntegrationConfig(
|
||||
Guid IntegrationId,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
string Endpoint,
|
||||
string? ResolvedSecret,
|
||||
string? OrganizationId,
|
||||
IReadOnlyDictionary<string, object>? ExtendedConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a test-connection operation.
|
||||
/// </summary>
|
||||
public sealed record TestConnectionResult(
|
||||
bool Success,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string>? Details,
|
||||
TimeSpan Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a health check operation.
|
||||
/// </summary>
|
||||
public sealed record HealthCheckResult(
|
||||
HealthStatus Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, string>? Details,
|
||||
DateTimeOffset CheckedAt,
|
||||
TimeSpan Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Integration lifecycle events for downstream consumers.
|
||||
/// </summary>
|
||||
public abstract record IntegrationEvent(Guid IntegrationId, DateTimeOffset OccurredAt);
|
||||
|
||||
public sealed record IntegrationCreatedEvent(
|
||||
Guid IntegrationId,
|
||||
string Name,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
string? CreatedBy,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
|
||||
public sealed record IntegrationUpdatedEvent(
|
||||
Guid IntegrationId,
|
||||
string Name,
|
||||
string? UpdatedBy,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
|
||||
public sealed record IntegrationDeletedEvent(
|
||||
Guid IntegrationId,
|
||||
string? DeletedBy,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
|
||||
public sealed record IntegrationStatusChangedEvent(
|
||||
Guid IntegrationId,
|
||||
IntegrationStatus OldStatus,
|
||||
IntegrationStatus NewStatus,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
|
||||
public sealed record IntegrationHealthChangedEvent(
|
||||
Guid IntegrationId,
|
||||
HealthStatus OldHealth,
|
||||
HealthStatus NewHealth,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
|
||||
public sealed record IntegrationTestConnectionEvent(
|
||||
Guid IntegrationId,
|
||||
bool Success,
|
||||
string? Message,
|
||||
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Core</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Repository contract for integration persistence.
|
||||
/// </summary>
|
||||
public interface IIntegrationRepository
|
||||
{
|
||||
Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default);
|
||||
Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default);
|
||||
Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default);
|
||||
Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default);
|
||||
Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for repository operations.
|
||||
/// </summary>
|
||||
public sealed record IntegrationQuery(
|
||||
IntegrationType? Type = null,
|
||||
IntegrationProvider? Provider = null,
|
||||
IntegrationStatus? Status = null,
|
||||
string? Search = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
string? TenantId = null,
|
||||
bool IncludeDeleted = false,
|
||||
int Skip = 0,
|
||||
int Take = 20,
|
||||
string SortBy = "name",
|
||||
bool SortDescending = false);
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Integration persistence.
|
||||
/// </summary>
|
||||
public sealed class IntegrationDbContext : DbContext
|
||||
{
|
||||
public IntegrationDbContext(DbContextOptions<IntegrationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<IntegrationEntity> Integrations => Set<IntegrationEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<IntegrationEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("integrations");
|
||||
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(256).IsRequired();
|
||||
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(1024);
|
||||
|
||||
entity.Property(e => e.Type).HasColumnName("type").IsRequired();
|
||||
entity.Property(e => e.Provider).HasColumnName("provider").IsRequired();
|
||||
entity.Property(e => e.Status).HasColumnName("status").IsRequired();
|
||||
|
||||
entity.Property(e => e.Endpoint).HasColumnName("endpoint").HasMaxLength(2048).IsRequired();
|
||||
entity.Property(e => e.AuthRefUri).HasColumnName("auth_ref_uri").HasMaxLength(1024);
|
||||
entity.Property(e => e.OrganizationId).HasColumnName("organization_id").HasMaxLength(256);
|
||||
entity.Property(e => e.ConfigJson).HasColumnName("config_json").HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.LastHealthStatus).HasColumnName("last_health_status");
|
||||
entity.Property(e => e.LastHealthCheckAt).HasColumnName("last_health_check_at");
|
||||
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by").HasMaxLength(256);
|
||||
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by").HasMaxLength(256);
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id").HasMaxLength(128);
|
||||
entity.Property(e => e.TagsJson).HasColumnName("tags").HasColumnType("jsonb");
|
||||
entity.Property(e => e.IsDeleted).HasColumnName("is_deleted").IsRequired();
|
||||
|
||||
entity.HasIndex(e => e.Type);
|
||||
entity.HasIndex(e => e.Provider);
|
||||
entity.HasIndex(e => e.Status);
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique().HasFilter("is_deleted = false");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for Integration.
|
||||
/// </summary>
|
||||
public sealed class IntegrationEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public IntegrationType Type { get; set; }
|
||||
public IntegrationProvider Provider { get; set; }
|
||||
public IntegrationStatus Status { get; set; }
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public string? AuthRefUri { get; set; }
|
||||
public string? OrganizationId { get; set; }
|
||||
public string? ConfigJson { get; set; }
|
||||
public HealthStatus LastHealthStatus { get; set; }
|
||||
public DateTimeOffset? LastHealthCheckAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? TagsJson { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of integration repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresIntegrationRepository : IIntegrationRepository
|
||||
{
|
||||
private readonly IntegrationDbContext _context;
|
||||
|
||||
public PostgresIntegrationRepository(IntegrationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _context.Integrations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, cancellationToken);
|
||||
|
||||
return entity is null ? null : MapToDomain(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dbQuery = BuildQuery(query);
|
||||
|
||||
dbQuery = query.SortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"name" => query.SortDescending ? dbQuery.OrderByDescending(e => e.Name) : dbQuery.OrderBy(e => e.Name),
|
||||
"createdat" => query.SortDescending ? dbQuery.OrderByDescending(e => e.CreatedAt) : dbQuery.OrderBy(e => e.CreatedAt),
|
||||
"updatedat" => query.SortDescending ? dbQuery.OrderByDescending(e => e.UpdatedAt) : dbQuery.OrderBy(e => e.UpdatedAt),
|
||||
"status" => query.SortDescending ? dbQuery.OrderByDescending(e => e.Status) : dbQuery.OrderBy(e => e.Status),
|
||||
_ => dbQuery.OrderBy(e => e.Name)
|
||||
};
|
||||
|
||||
var entities = await dbQuery
|
||||
.Skip(query.Skip)
|
||||
.Take(query.Take)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await BuildQuery(query).CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = MapToEntity(integration);
|
||||
_context.Integrations.Add(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return MapToDomain(entity);
|
||||
}
|
||||
|
||||
public async Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _context.Integrations
|
||||
.FirstOrDefaultAsync(e => e.Id == integration.Id, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Integration {integration.Id} not found");
|
||||
|
||||
entity.Name = integration.Name;
|
||||
entity.Description = integration.Description;
|
||||
entity.Status = integration.Status;
|
||||
entity.Endpoint = integration.Endpoint;
|
||||
entity.AuthRefUri = integration.AuthRefUri;
|
||||
entity.OrganizationId = integration.OrganizationId;
|
||||
entity.ConfigJson = integration.ConfigJson;
|
||||
entity.LastHealthStatus = integration.LastHealthStatus;
|
||||
entity.LastHealthCheckAt = integration.LastHealthCheckAt;
|
||||
entity.UpdatedAt = integration.UpdatedAt;
|
||||
entity.UpdatedBy = integration.UpdatedBy;
|
||||
entity.TagsJson = integration.Tags.Count > 0 ? JsonSerializer.Serialize(integration.Tags) : null;
|
||||
entity.IsDeleted = integration.IsDeleted;
|
||||
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return MapToDomain(entity);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _context.Integrations
|
||||
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
|
||||
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.IsDeleted = true;
|
||||
entity.Status = IntegrationStatus.Archived;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _context.Integrations
|
||||
.Where(e => e.Provider == provider && !e.IsDeleted)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _context.Integrations
|
||||
.Where(e => e.Type == type && e.Status == IntegrationStatus.Active && !e.IsDeleted)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
public async Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _context.Integrations
|
||||
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
|
||||
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.LastHealthStatus = status;
|
||||
entity.LastHealthCheckAt = checkedAt;
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<IntegrationEntity> BuildQuery(IntegrationQuery query)
|
||||
{
|
||||
var dbQuery = _context.Integrations.AsQueryable();
|
||||
|
||||
if (!query.IncludeDeleted)
|
||||
{
|
||||
dbQuery = dbQuery.Where(e => !e.IsDeleted);
|
||||
}
|
||||
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
dbQuery = dbQuery.Where(e => e.TenantId == query.TenantId);
|
||||
}
|
||||
|
||||
if (query.Type.HasValue)
|
||||
{
|
||||
dbQuery = dbQuery.Where(e => e.Type == query.Type.Value);
|
||||
}
|
||||
|
||||
if (query.Provider.HasValue)
|
||||
{
|
||||
dbQuery = dbQuery.Where(e => e.Provider == query.Provider.Value);
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
dbQuery = dbQuery.Where(e => e.Status == query.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var searchLower = query.Search.ToLowerInvariant();
|
||||
dbQuery = dbQuery.Where(e =>
|
||||
e.Name.ToLower().Contains(searchLower) ||
|
||||
(e.Description != null && e.Description.ToLower().Contains(searchLower)));
|
||||
}
|
||||
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
private static Integration MapToDomain(IntegrationEntity entity)
|
||||
{
|
||||
var tags = string.IsNullOrEmpty(entity.TagsJson)
|
||||
? new List<string>()
|
||||
: JsonSerializer.Deserialize<List<string>>(entity.TagsJson) ?? new List<string>();
|
||||
|
||||
return new Integration
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Type = entity.Type,
|
||||
Provider = entity.Provider,
|
||||
Status = entity.Status,
|
||||
Endpoint = entity.Endpoint,
|
||||
AuthRefUri = entity.AuthRefUri,
|
||||
OrganizationId = entity.OrganizationId,
|
||||
ConfigJson = entity.ConfigJson,
|
||||
LastHealthStatus = entity.LastHealthStatus,
|
||||
LastHealthCheckAt = entity.LastHealthCheckAt,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
CreatedBy = entity.CreatedBy,
|
||||
UpdatedBy = entity.UpdatedBy,
|
||||
TenantId = entity.TenantId,
|
||||
Tags = tags,
|
||||
IsDeleted = entity.IsDeleted
|
||||
};
|
||||
}
|
||||
|
||||
private static IntegrationEntity MapToEntity(Integration integration)
|
||||
{
|
||||
return new IntegrationEntity
|
||||
{
|
||||
Id = integration.Id,
|
||||
Name = integration.Name,
|
||||
Description = integration.Description,
|
||||
Type = integration.Type,
|
||||
Provider = integration.Provider,
|
||||
Status = integration.Status,
|
||||
Endpoint = integration.Endpoint,
|
||||
AuthRefUri = integration.AuthRefUri,
|
||||
OrganizationId = integration.OrganizationId,
|
||||
ConfigJson = integration.ConfigJson,
|
||||
LastHealthStatus = integration.LastHealthStatus,
|
||||
LastHealthCheckAt = integration.LastHealthCheckAt,
|
||||
CreatedAt = integration.CreatedAt,
|
||||
UpdatedAt = integration.UpdatedAt,
|
||||
CreatedBy = integration.CreatedBy,
|
||||
UpdatedBy = integration.UpdatedBy,
|
||||
TenantId = integration.TenantId,
|
||||
TagsJson = integration.Tags.Count > 0 ? JsonSerializer.Serialize(integration.Tags) : null,
|
||||
IsDeleted = integration.IsDeleted
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Persistence</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.GitHubApp;
|
||||
|
||||
/// <summary>
|
||||
/// GitHub App connector plugin for SCM integration.
|
||||
/// Supports GitHub.com and GitHub Enterprise Server.
|
||||
/// </summary>
|
||||
public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string Name => "github-app";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Scm;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.GitHubApp;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Call GitHub API to verify authentication
|
||||
var response = await client.GetAsync("/app", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var app = JsonSerializer.Deserialize<GitHubAppResponse>(content, JsonOptions);
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: $"Connected as GitHub App: {app?.Name}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["appName"] = app?.Name ?? "unknown",
|
||||
["appId"] = app?.Id.ToString() ?? "unknown",
|
||||
["slug"] = app?.Slug ?? "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"GitHub returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString(),
|
||||
["error"] = errorContent.Length > 200 ? errorContent[..200] : errorContent
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Connection failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Check GitHub API status
|
||||
var response = await client.GetAsync("/rate_limit", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var rateLimit = JsonSerializer.Deserialize<GitHubRateLimitResponse>(content, JsonOptions);
|
||||
|
||||
var remaining = rateLimit?.Resources?.Core?.Remaining ?? 0;
|
||||
var limit = rateLimit?.Resources?.Core?.Limit ?? 1;
|
||||
var percentUsed = (int)((1 - (double)remaining / limit) * 100);
|
||||
|
||||
var status = percentUsed switch
|
||||
{
|
||||
< 80 => HealthStatus.Healthy,
|
||||
< 95 => HealthStatus.Degraded,
|
||||
_ => HealthStatus.Unhealthy
|
||||
};
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: status,
|
||||
Message: $"Rate limit: {remaining}/{limit} remaining ({percentUsed}% used)",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["remaining"] = remaining.ToString(),
|
||||
["limit"] = limit.ToString(),
|
||||
["percentUsed"] = percentUsed.ToString()
|
||||
},
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"GitHub returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Health check failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com"
|
||||
? "https://api.github.com"
|
||||
: config.Endpoint.TrimEnd('/') + "/api/v3";
|
||||
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
|
||||
|
||||
// Add JWT token if provided (GitHub App authentication)
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private sealed class GitHubAppResponse
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Slug { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubRateLimitResponse
|
||||
{
|
||||
public GitHubResources? Resources { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubResources
|
||||
{
|
||||
public GitHubRateLimit? Core { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubRateLimit
|
||||
{
|
||||
public int Limit { get; set; }
|
||||
public int Remaining { get; set; }
|
||||
public int Reset { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Plugin.GitHubApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Harbor;
|
||||
|
||||
/// <summary>
|
||||
/// Harbor container registry connector plugin.
|
||||
/// Supports Harbor v2.x API.
|
||||
/// </summary>
|
||||
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string Name => "harbor";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.Harbor;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
// Call Harbor health endpoint
|
||||
var response = await client.GetAsync("/api/v2.0/health", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var health = JsonSerializer.Deserialize<HarborHealthResponse>(content, JsonOptions);
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: health?.Status == "healthy",
|
||||
Message: health?.Status == "healthy" ? "Harbor connection successful" : $"Harbor unhealthy: {health?.Status}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["status"] = health?.Status ?? "unknown",
|
||||
["version"] = response.Headers.TryGetValues("X-Harbor-Version", out var versions)
|
||||
? versions.FirstOrDefault() ?? "unknown"
|
||||
: "unknown"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Harbor returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString()
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
return new TestConnectionResult(
|
||||
Success: false,
|
||||
Message: $"Connection failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["error"] = ex.GetType().Name
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
using var client = CreateHttpClient(config);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync("/api/v2.0/health", cancellationToken);
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var health = JsonSerializer.Deserialize<HarborHealthResponse>(content, JsonOptions);
|
||||
|
||||
var status = health?.Status switch
|
||||
{
|
||||
"healthy" => HealthStatus.Healthy,
|
||||
"degraded" => HealthStatus.Degraded,
|
||||
_ => HealthStatus.Unhealthy
|
||||
};
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: status,
|
||||
Message: $"Harbor status: {health?.Status}",
|
||||
Details: health?.Components?.ToDictionary(c => c.Name, c => c.Status) ?? new Dictionary<string, string>(),
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Harbor returned {response.StatusCode}",
|
||||
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Unhealthy,
|
||||
Message: $"Health check failed: {ex.Message}",
|
||||
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
// Add basic auth if secret is provided
|
||||
if (!string.IsNullOrEmpty(config.ResolvedSecret))
|
||||
{
|
||||
// Expect format: username:password
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private sealed class HarborHealthResponse
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
public List<HarborHealthComponent>? Components { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HarborHealthComponent
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Plugin.Harbor</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.InMemory;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory connector plugin for testing and development.
|
||||
/// Always succeeds with simulated delays.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConnectorPlugin : IIntegrationConnectorPlugin
|
||||
{
|
||||
public string Name => "inmemory";
|
||||
|
||||
public IntegrationType Type => IntegrationType.Registry;
|
||||
|
||||
public IntegrationProvider Provider => IntegrationProvider.InMemory;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Simulate network delay
|
||||
await Task.Delay(100, cancellationToken);
|
||||
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
return new TestConnectionResult(
|
||||
Success: true,
|
||||
Message: "In-memory connector test successful",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["provider"] = config.Provider.ToString(),
|
||||
["simulated"] = "true"
|
||||
},
|
||||
Duration: duration);
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Simulate health check
|
||||
await Task.Delay(50, cancellationToken);
|
||||
|
||||
var duration = DateTimeOffset.UtcNow - startTime;
|
||||
|
||||
return new HealthCheckResult(
|
||||
Status: HealthStatus.Healthy,
|
||||
Message: "In-memory connector is healthy",
|
||||
Details: new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = config.Endpoint,
|
||||
["uptime"] = "simulated"
|
||||
},
|
||||
CheckedAt: DateTimeOffset.UtcNow,
|
||||
Duration: duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Integrations.Plugin.InMemory</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests;
|
||||
|
||||
public class IntegrationPluginLoaderTests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void Plugins_ReturnsEmptyInitially()
|
||||
{
|
||||
// Arrange
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
// Act
|
||||
var plugins = loader.Plugins;
|
||||
|
||||
// Assert
|
||||
plugins.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetByProvider_WithNoPlugins_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
// Act
|
||||
var result = loader.GetByProvider(IntegrationProvider.Harbor);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetByType_WithNoPlugins_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
// Act
|
||||
var result = loader.GetByType(IntegrationType.Registry);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromDirectory_WithNonExistentDirectory_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
// Act
|
||||
var result = loader.LoadFromDirectory("/non/existent/path");
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void LoadFromAssemblies_WithEmptyAssemblies_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
// Act
|
||||
var result = loader.LoadFromAssemblies([]);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests;
|
||||
|
||||
public class IntegrationServiceTests
|
||||
{
|
||||
private readonly Mock<IIntegrationRepository> _repositoryMock;
|
||||
private readonly Mock<IIntegrationEventPublisher> _eventPublisherMock;
|
||||
private readonly Mock<IIntegrationAuditLogger> _auditLoggerMock;
|
||||
private readonly Mock<IAuthRefResolver> _authRefResolverMock;
|
||||
private readonly IntegrationPluginLoader _pluginLoader;
|
||||
private readonly IntegrationService _service;
|
||||
|
||||
public IntegrationServiceTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IIntegrationRepository>();
|
||||
_eventPublisherMock = new Mock<IIntegrationEventPublisher>();
|
||||
_auditLoggerMock = new Mock<IIntegrationAuditLogger>();
|
||||
_authRefResolverMock = new Mock<IAuthRefResolver>();
|
||||
_pluginLoader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
|
||||
|
||||
_service = new IntegrationService(
|
||||
_repositoryMock.Object,
|
||||
_pluginLoader,
|
||||
_eventPublisherMock.Object,
|
||||
_auditLoggerMock.Object,
|
||||
_authRefResolverMock.Object,
|
||||
NullLogger<IntegrationService>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidRequest_CreatesIntegration()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateIntegrationRequest(
|
||||
Name: "Test Registry",
|
||||
Description: "Test description",
|
||||
Type: IntegrationType.Registry,
|
||||
Provider: IntegrationProvider.Harbor,
|
||||
Endpoint: "https://harbor.example.com",
|
||||
AuthRefUri: "authref://vault/harbor#credentials",
|
||||
OrganizationId: "myorg",
|
||||
ExtendedConfig: null,
|
||||
Tags: ["test", "dev"]);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request, "test-user", "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Name.Should().Be("Test Registry");
|
||||
result.Type.Should().Be(IntegrationType.Registry);
|
||||
result.Provider.Should().Be(IntegrationProvider.Harbor);
|
||||
result.Status.Should().Be(IntegrationStatus.Pending);
|
||||
result.Endpoint.Should().Be("https://harbor.example.com");
|
||||
|
||||
_repositoryMock.Verify(r => r.CreateAsync(
|
||||
It.IsAny<Integration>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationCreatedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_auditLoggerMock.Verify(a => a.LogAsync(
|
||||
"integration.created",
|
||||
It.IsAny<Guid>(),
|
||||
"test-user",
|
||||
It.IsAny<object>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithExistingId_ReturnsIntegration()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(integration.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(integration.Id);
|
||||
result.Name.Should().Be(integration.Name);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithNonExistingId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(id);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
|
||||
{
|
||||
// Arrange
|
||||
var integrations = new[]
|
||||
{
|
||||
CreateTestIntegration(type: IntegrationType.Registry),
|
||||
CreateTestIntegration(type: IntegrationType.Registry),
|
||||
CreateTestIntegration(type: IntegrationType.Scm)
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetAllAsync(
|
||||
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integrations.Where(i => i.Type == IntegrationType.Registry).ToList());
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.CountAsync(
|
||||
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(2);
|
||||
|
||||
var query = new ListIntegrationsQuery(Type: IntegrationType.Registry);
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync(query, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Should().OnlyContain(i => i.Type == IntegrationType.Registry);
|
||||
result.TotalCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithExistingIntegration_UpdatesAndPublishesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
var request = new UpdateIntegrationRequest(
|
||||
Name: "Updated Name",
|
||||
Description: "Updated description",
|
||||
Endpoint: "https://updated.example.com",
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: ["updated"],
|
||||
Status: null);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
_repositoryMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(integration.Id, request, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Name.Should().Be("Updated Name");
|
||||
result.Description.Should().Be("Updated description");
|
||||
result.Endpoint.Should().Be("https://updated.example.com");
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationUpdatedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
var request = new UpdateIntegrationRequest(
|
||||
Name: "Updated", Description: null, Endpoint: null,
|
||||
AuthRefUri: null, OrganizationId: null, ExtendedConfig: null,
|
||||
Tags: null, Status: null);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(id, request, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WithExistingIntegration_DeletesAndPublishesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
_repositoryMock
|
||||
.Setup(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(integration.Id, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
_repositoryMock.Verify(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationDeletedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WithNonExistingIntegration_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(id, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResult()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor);
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// No plugins loaded in _pluginLoader
|
||||
|
||||
// Act
|
||||
var result = await _service.TestConnectionAsync(integration.Id, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("No connector plugin");
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.TestConnectionAsync(id, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatus()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// No plugins loaded
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckHealthAsync(integration.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(HealthStatus.Unknown);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = _service.GetSupportedProviders();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static Integration CreateTestIntegration(
|
||||
IntegrationType type = IntegrationType.Registry,
|
||||
IntegrationProvider provider = IntegrationProvider.Harbor)
|
||||
{
|
||||
return new Integration
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Integration",
|
||||
Type = type,
|
||||
Provider = provider,
|
||||
Status = IntegrationStatus.Active,
|
||||
Endpoint = "https://example.com",
|
||||
Description = "Test description",
|
||||
Tags = ["test"],
|
||||
CreatedBy = "test-user"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Contracts/StellaOps.Integrations.Contracts.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Core/StellaOps.Integrations.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user