Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,124 @@
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Service for managing SBOM sources.
/// </summary>
public interface ISbomSourceService
{
/// <summary>
/// Get a source by ID.
/// </summary>
Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>
Task<SourceResponse?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default);
/// <summary>
/// List sources with optional filters.
/// </summary>
Task<PagedResponse<SourceResponse>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default);
/// <summary>
/// Create a new source.
/// </summary>
Task<SourceResponse> CreateAsync(
string tenantId,
CreateSourceRequest request,
string createdBy,
CancellationToken ct = default);
/// <summary>
/// Update an existing source.
/// </summary>
Task<SourceResponse> UpdateAsync(
string tenantId,
Guid sourceId,
UpdateSourceRequest request,
string updatedBy,
CancellationToken ct = default);
/// <summary>
/// Delete a source.
/// </summary>
Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Test source connection.
/// </summary>
Task<ConnectionTestResult> TestConnectionAsync(
string tenantId,
Guid sourceId,
CancellationToken ct = default);
/// <summary>
/// Test connection for a new source (before creation).
/// </summary>
Task<ConnectionTestResult> TestNewConnectionAsync(
string tenantId,
TestConnectionRequest request,
CancellationToken ct = default);
/// <summary>
/// Pause a source.
/// </summary>
Task<SourceResponse> PauseAsync(
string tenantId,
Guid sourceId,
PauseSourceRequest request,
string pausedBy,
CancellationToken ct = default);
/// <summary>
/// Resume a paused source.
/// </summary>
Task<SourceResponse> ResumeAsync(
string tenantId,
Guid sourceId,
string resumedBy,
CancellationToken ct = default);
/// <summary>
/// Trigger a manual scan for a source.
/// </summary>
Task<TriggerScanResult> TriggerScanAsync(
string tenantId,
Guid sourceId,
TriggerScanRequest? request,
string triggeredBy,
CancellationToken ct = default);
/// <summary>
/// Get run history for a source.
/// </summary>
Task<PagedResponse<SourceRunResponse>> GetRunsAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default);
/// <summary>
/// Get run details.
/// </summary>
Task<SourceRunResponse?> GetRunAsync(
string tenantId,
Guid sourceId,
Guid runId,
CancellationToken ct = default);
/// <summary>
/// Activate a source (after validation).
/// </summary>
Task<SourceResponse> ActivateAsync(
string tenantId,
Guid sourceId,
string activatedBy,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Interface for testing source connections.
/// </summary>
public interface ISourceConnectionTester
{
/// <summary>
/// Tests connection to the source using stored credentials.
/// </summary>
Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default);
/// <summary>
/// Tests connection using provided test credentials (for setup validation).
/// </summary>
Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? testCredentials,
CancellationToken ct = default);
}
/// <summary>
/// Interface for type-specific connection testing.
/// </summary>
public interface ISourceTypeConnectionTester
{
/// <summary>
/// The source type this tester handles.
/// </summary>
SbomSourceType SourceType { get; }
/// <summary>
/// Tests connection to the source.
/// </summary>
Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? overrideCredentials,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,422 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Persistence;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Service for managing SBOM sources.
/// </summary>
public sealed class SbomSourceService : ISbomSourceService
{
private readonly ISbomSourceRepository _sourceRepository;
private readonly ISbomSourceRunRepository _runRepository;
private readonly ISourceConfigValidator _configValidator;
private readonly ISourceConnectionTester _connectionTester;
private readonly ILogger<SbomSourceService> _logger;
public SbomSourceService(
ISbomSourceRepository sourceRepository,
ISbomSourceRunRepository runRepository,
ISourceConfigValidator configValidator,
ISourceConnectionTester connectionTester,
ILogger<SbomSourceService> logger)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
_configValidator = configValidator;
_connectionTester = connectionTester;
_logger = logger;
}
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct);
return source == null ? null : SourceResponse.FromDomain(source);
}
public async Task<SourceResponse?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByNameAsync(tenantId, name, ct);
return source == null ? null : SourceResponse.FromDomain(source);
}
public async Task<PagedResponse<SourceResponse>> ListAsync(
string tenantId,
ListSourcesRequest request,
CancellationToken ct = default)
{
var result = await _sourceRepository.ListAsync(tenantId, request, ct);
return new PagedResponse<SourceResponse>
{
Items = result.Items.Select(SourceResponse.FromDomain).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor
};
}
public async Task<SourceResponse> CreateAsync(
string tenantId,
CreateSourceRequest request,
string createdBy,
CancellationToken ct = default)
{
// Validate name uniqueness
if (await _sourceRepository.NameExistsAsync(tenantId, request.Name, ct: ct))
{
throw new InvalidOperationException($"Source with name '{request.Name}' already exists");
}
// Validate configuration
var validationResult = _configValidator.Validate(request.SourceType, request.Configuration);
if (!validationResult.IsValid)
{
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
// Validate cron schedule if provided
if (!string.IsNullOrEmpty(request.CronSchedule))
{
try
{
Cronos.CronExpression.Parse(request.CronSchedule);
}
catch (Exception ex)
{
throw new ArgumentException($"Invalid cron schedule: {ex.Message}", ex);
}
}
// Create domain entity
var source = SbomSource.Create(
tenantId,
request.Name,
request.SourceType,
request.Configuration,
createdBy,
request.Description,
request.AuthRef,
request.CronSchedule,
request.CronTimezone);
if (request.MaxScansPerHour.HasValue)
{
source.MaxScansPerHour = request.MaxScansPerHour;
}
if (request.Tags != null)
{
source.Tags = request.Tags;
}
if (request.Metadata != null)
{
source.Metadata = request.Metadata;
}
await _sourceRepository.CreateAsync(source, ct);
_logger.LogInformation(
"Created source {SourceId} ({Name}) of type {SourceType} for tenant {TenantId}",
source.SourceId, source.Name, source.SourceType, tenantId);
return SourceResponse.FromDomain(source);
}
public async Task<SourceResponse> UpdateAsync(
string tenantId,
Guid sourceId,
UpdateSourceRequest request,
string updatedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
// Validate name uniqueness if changed
if (request.Name != null && request.Name != source.Name)
{
if (await _sourceRepository.NameExistsAsync(tenantId, request.Name, sourceId, ct))
{
throw new InvalidOperationException($"Source with name '{request.Name}' already exists");
}
}
// Validate configuration if provided
if (request.Configuration != null)
{
var validationResult = _configValidator.Validate(source.SourceType, request.Configuration);
if (!validationResult.IsValid)
{
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
source.UpdateConfiguration(request.Configuration, updatedBy);
}
// Validate cron schedule if provided
if (request.CronSchedule != null)
{
if (!string.IsNullOrEmpty(request.CronSchedule))
{
try
{
Cronos.CronExpression.Parse(request.CronSchedule);
}
catch (Exception ex)
{
throw new ArgumentException($"Invalid cron schedule: {ex.Message}", ex);
}
}
source.CronSchedule = request.CronSchedule;
source.CalculateNextScheduledRun();
}
// Update simple fields via reflection (maintaining encapsulation)
if (request.Name != null)
{
SetProperty(source, "Name", request.Name);
}
if (request.Description != null)
{
source.Description = request.Description;
}
if (request.AuthRef != null)
{
source.AuthRef = request.AuthRef;
}
if (request.CronTimezone != null)
{
source.CronTimezone = request.CronTimezone;
source.CalculateNextScheduledRun();
}
if (request.MaxScansPerHour.HasValue)
{
source.MaxScansPerHour = request.MaxScansPerHour;
}
if (request.Tags != null)
{
source.Tags = request.Tags;
}
if (request.Metadata != null)
{
source.Metadata = request.Metadata;
}
// Touch updated fields
SetProperty(source, "UpdatedAt", DateTimeOffset.UtcNow);
SetProperty(source, "UpdatedBy", updatedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Updated source {SourceId} ({Name}) for tenant {TenantId}",
source.SourceId, source.Name, tenantId);
return SourceResponse.FromDomain(source);
}
public async Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
await _sourceRepository.DeleteAsync(tenantId, sourceId, ct);
_logger.LogInformation(
"Deleted source {SourceId} ({Name}) for tenant {TenantId}",
sourceId, source.Name, tenantId);
}
public async Task<ConnectionTestResult> TestConnectionAsync(
string tenantId,
Guid sourceId,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
return await _connectionTester.TestAsync(source, ct);
}
public async Task<ConnectionTestResult> TestNewConnectionAsync(
string tenantId,
TestConnectionRequest request,
CancellationToken ct = default)
{
// Create a temporary source for testing
var tempSource = SbomSource.Create(
tenantId,
"__test__",
request.SourceType,
request.Configuration,
"__test__",
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
}
public async Task<SourceResponse> PauseAsync(
string tenantId,
Guid sourceId,
PauseSourceRequest request,
string pausedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Pause(request.Reason, request.Ticket, pausedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Paused source {SourceId} ({Name}) by {PausedBy}: {Reason}",
sourceId, source.Name, pausedBy, request.Reason);
return SourceResponse.FromDomain(source);
}
public async Task<SourceResponse> ResumeAsync(
string tenantId,
Guid sourceId,
string resumedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Resume(resumedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Resumed source {SourceId} ({Name}) by {ResumedBy}",
sourceId, source.Name, resumedBy);
return SourceResponse.FromDomain(source);
}
public async Task<TriggerScanResult> TriggerScanAsync(
string tenantId,
Guid sourceId,
TriggerScanRequest? request,
string triggeredBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
// Check if source can be triggered
if (source.Status == SbomSourceStatus.Disabled)
{
throw new InvalidOperationException("Cannot trigger a disabled source");
}
if (source.Paused && request?.Force != true)
{
throw new InvalidOperationException($"Source is paused: {source.PauseReason}");
}
if (source.IsRateLimited() && request?.Force != true)
{
throw new InvalidOperationException("Source is rate limited. Use force=true to override.");
}
// Create a run record
var run = SbomSourceRun.Create(
sourceId,
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);
_logger.LogInformation(
"Triggered manual scan for source {SourceId} ({Name}), run {RunId}",
sourceId, source.Name, run.RunId);
// TODO: Actually dispatch the scan to the trigger service
// For now, just return the run info
return new TriggerScanResult
{
RunId = run.RunId,
Status = run.Status,
Message = "Scan triggered successfully"
};
}
public async Task<PagedResponse<SourceRunResponse>> GetRunsAsync(
string tenantId,
Guid sourceId,
ListSourceRunsRequest request,
CancellationToken ct = default)
{
// Verify source exists and belongs to tenant
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var result = await _runRepository.ListForSourceAsync(sourceId, request, ct);
return new PagedResponse<SourceRunResponse>
{
Items = result.Items.Select(SourceRunResponse.FromDomain).ToList(),
TotalCount = result.TotalCount,
NextCursor = result.NextCursor
};
}
public async Task<SourceRunResponse?> GetRunAsync(
string tenantId,
Guid sourceId,
Guid runId,
CancellationToken ct = default)
{
// Verify source exists and belongs to tenant
_ = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
var run = await _runRepository.GetByIdAsync(runId, ct);
if (run == null || run.SourceId != sourceId)
{
return null;
}
return SourceRunResponse.FromDomain(run);
}
public async Task<SourceResponse> ActivateAsync(
string tenantId,
Guid sourceId,
string activatedBy,
CancellationToken ct = default)
{
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Activate(activatedBy);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
"Activated source {SourceId} ({Name}) by {ActivatedBy}",
sourceId, source.Name, activatedBy);
return SourceResponse.FromDomain(source);
}
private static void SetProperty(object obj, string propertyName, object value)
{
var property = obj.GetType().GetProperty(propertyName);
property?.SetValue(obj, value);
}
}

View File

@@ -0,0 +1,85 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Services;
/// <summary>
/// Dispatches connection tests to type-specific testers.
/// </summary>
public sealed class SourceConnectionTester : ISourceConnectionTester
{
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
private readonly ILogger<SourceConnectionTester> _logger;
public SourceConnectionTester(
IEnumerable<ISourceTypeConnectionTester> testers,
ILogger<SourceConnectionTester> logger)
{
_testers = testers;
_logger = logger;
}
public Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default)
{
return TestAsync(source, null, ct);
}
public async Task<ConnectionTestResult> TestAsync(
SbomSource source,
JsonDocument? testCredentials,
CancellationToken ct = default)
{
var tester = _testers.FirstOrDefault(t => t.SourceType == source.SourceType);
if (tester == null)
{
_logger.LogWarning(
"No connection tester registered for source type {SourceType}",
source.SourceType);
return new ConnectionTestResult
{
Success = false,
Message = $"No connection tester available for source type {source.SourceType}",
TestedAt = DateTimeOffset.UtcNow
};
}
try
{
_logger.LogDebug(
"Testing connection for source {SourceId} ({SourceType})",
source.SourceId, source.SourceType);
var result = await tester.TestAsync(source, testCredentials, ct);
_logger.LogInformation(
"Connection test for source {SourceId} {Result}: {Message}",
source.SourceId,
result.Success ? "succeeded" : "failed",
result.Message);
return result;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Connection test failed for source {SourceId}", source.SourceId);
return new ConnectionTestResult
{
Success = false,
Message = $"Connection test error: {ex.Message}",
TestedAt = DateTimeOffset.UtcNow,
Details = new Dictionary<string, object>
{
["exceptionType"] = ex.GetType().Name
}
};
}
}
}