feat: Document completed tasks for KMS, Cryptography, and Plugin Libraries
- Added detailed task completion records for KMS interface implementation and CLI support for file-based keys. - Documented security enhancements including Argon2id password hashing, audit event contracts, and rate limiting configurations. - Included scoped service support and integration updates for the Plugin platform, ensuring proper DI handling and testing coverage.
This commit is contained in:
@@ -1,41 +1,185 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
|
||||
private readonly ILogger<GraphJobEventPublisher> _logger;
|
||||
|
||||
public GraphJobEventPublisher(
|
||||
IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
ILogger<GraphJobEventPublisher> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
if (!options.GraphJobs.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
_logger.LogInformation("{EventJson}", json);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher, IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<SchedulerEventsOptions> _options;
|
||||
private readonly IRedisConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<GraphJobEventPublisher> _logger;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
|
||||
public GraphJobEventPublisher(
|
||||
IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
IRedisConnectionFactory connectionFactory,
|
||||
ILogger<GraphJobEventPublisher> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(notification));
|
||||
}
|
||||
|
||||
var options = _options.CurrentValue?.GraphJobs ?? new GraphJobEventsOptions();
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(options.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Graph job events configured with unsupported driver '{Driver}'. Falling back to logging.",
|
||||
options.Driver);
|
||||
LogEnvelope(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var database = await GetDatabaseAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var payload = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
var entries = new[]
|
||||
{
|
||||
new NameValueEntry("event", payload),
|
||||
new NameValueEntry("kind", envelope.Kind),
|
||||
new NameValueEntry("tenant", envelope.Tenant),
|
||||
new NameValueEntry("occurredAt", envelope.Timestamp.ToString("O")),
|
||||
new NameValueEntry("jobId", notification.Job.Id),
|
||||
new NameValueEntry("status", notification.Status.ToString())
|
||||
};
|
||||
|
||||
var streamKey = string.IsNullOrWhiteSpace(options.Stream) ? "stella.events" : options.Stream;
|
||||
var publishTask = CreatePublishTask(database, streamKey, entries, options.MaxStreamLength);
|
||||
|
||||
if (options.PublishTimeoutSeconds > 0)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(options.PublishTimeoutSeconds);
|
||||
await publishTask.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await publishTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Published graph job event {JobId} to stream {Stream}.", notification.Job.Id, streamKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish graph job completion for {JobId}; logging payload instead.", notification.Job.Id);
|
||||
LogEnvelope(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<RedisValue> CreatePublishTask(IDatabase database, string streamKey, NameValueEntry[] entries, long maxStreamLength)
|
||||
{
|
||||
if (maxStreamLength > 0)
|
||||
{
|
||||
var clamped = (int)Math.Min(maxStreamLength, int.MaxValue);
|
||||
return database.StreamAddAsync(streamKey, entries, maxLength: clamped, useApproximateMaxLength: true);
|
||||
}
|
||||
|
||||
return database.StreamAddAsync(streamKey, entries);
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(GraphJobEventsOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_connection is { IsConnected: true })
|
||||
{
|
||||
return _connection.GetDatabase();
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is null || !_connection.IsConnected)
|
||||
{
|
||||
var configuration = ConfigurationOptions.Parse(options.Dsn);
|
||||
configuration.AbortOnConnectFail = false;
|
||||
|
||||
if (options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName))
|
||||
{
|
||||
configuration.ClientName = clientName;
|
||||
}
|
||||
|
||||
if (options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl))
|
||||
{
|
||||
configuration.Ssl = ssl;
|
||||
}
|
||||
|
||||
if (options.DriverSettings.TryGetValue("password", out var password) && !string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
configuration.Password = password;
|
||||
}
|
||||
|
||||
_connection = await _connectionFactory.ConnectAsync(configuration, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Connected graph job publisher to Redis stream {Stream}.", options.Stream);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
|
||||
return _connection!.GetDatabase();
|
||||
}
|
||||
|
||||
private void LogEnvelope(GraphJobCompletionNotification notification)
|
||||
{
|
||||
var envelope = GraphJobEventFactory.Create(notification);
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
_logger.LogInformation("{EventJson}", json);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error while closing graph job Redis connection.");
|
||||
}
|
||||
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal interface IRedisConnectionFactory
|
||||
{
|
||||
Task<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
|
||||
internal sealed class RedisConnectionFactory : IRedisConnectionFactory
|
||||
{
|
||||
public async Task<IConnectionMultiplexer> ConnectAsync(ConfigurationOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var completionSource = new TaskCompletionSource<IConnectionMultiplexer>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
cancellationToken.Register(() => completionSource.TrySetCanceled(cancellationToken));
|
||||
|
||||
try
|
||||
{
|
||||
var connection = await ConnectionMultiplexer.ConnectAsync(options).ConfigureAwait(false);
|
||||
completionSource.TrySetResult(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completionSource.TrySetException(ex);
|
||||
}
|
||||
|
||||
return await completionSource.Task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user