consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
|
||||
{
|
||||
public const string ClientName = "UnifiedAuditModules";
|
||||
|
||||
private static readonly StringComparer StringComparerOrdinal = StringComparer.Ordinal;
|
||||
private static readonly DateTimeOffset TimestampFallback = DateTimeOffset.UnixEpoch;
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IOptions<UnifiedAuditModuleEndpointsOptions> _options;
|
||||
private readonly ILogger<HttpUnifiedAuditEventProvider> _logger;
|
||||
|
||||
public HttpUnifiedAuditEventProvider(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<UnifiedAuditModuleEndpointsOptions> options,
|
||||
ILogger<HttpUnifiedAuditEventProvider> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var endpointOptions = _options.Value;
|
||||
var timeout = TimeSpan.FromSeconds(Math.Max(1, endpointOptions.RequestTimeoutSeconds));
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
var getJobEngine = GetJobEngineEventsAsync(endpointOptions, timeoutSource.Token);
|
||||
var getPolicy = GetPolicyEventsAsync(endpointOptions, timeoutSource.Token);
|
||||
var getEvidence = GetEvidenceLockerEventsAsync(endpointOptions, timeoutSource.Token);
|
||||
var getNotify = GetNotifyEventsAsync(endpointOptions, timeoutSource.Token);
|
||||
|
||||
await Task.WhenAll(getJobEngine, getPolicy, getEvidence, getNotify).ConfigureAwait(false);
|
||||
|
||||
return getJobEngine.Result
|
||||
.Concat(getPolicy.Result)
|
||||
.Concat(getEvidence.Result)
|
||||
.Concat(getNotify.Result)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ThenBy(e => e.Id, StringComparerOrdinal)
|
||||
.ThenBy(e => e.Module, StringComparerOrdinal)
|
||||
.ThenBy(e => e.Action, StringComparerOrdinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetJobEngineEventsAsync(
|
||||
UnifiedAuditModuleEndpointsOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
options.JobEngineBaseUrl,
|
||||
"/api/v1/jobengine/audit",
|
||||
new Dictionary<string, string?> { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) });
|
||||
|
||||
if (uri is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(document.RootElement, "entries", out var entries) ||
|
||||
entries.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
var events = new List<UnifiedAuditEvent>();
|
||||
foreach (var entry in entries.EnumerateArray())
|
||||
{
|
||||
var entryId = GetString(entry, "entryId");
|
||||
if (string.IsNullOrWhiteSpace(entryId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = UnifiedAuditValueMapper.NormalizeAction(GetString(entry, "eventType"), GetString(entry, "description"));
|
||||
var description = GetString(entry, "description") ?? GetString(entry, "eventType") ?? "JobEngine audit event";
|
||||
var occurredAt = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "occurredAt"), TimestampFallback);
|
||||
|
||||
var resourceType = GetString(entry, "resourceType") ?? "jobengine_resource";
|
||||
var resourceId = GetString(entry, "resourceId") ?? entryId;
|
||||
|
||||
var actorId = GetString(entry, "actorId") ?? "jobengine-system";
|
||||
var actorType = UnifiedAuditValueMapper.NormalizeActorType(GetString(entry, "actorType"));
|
||||
var severity = UnifiedAuditValueMapper.NormalizeSeverity(
|
||||
GetString(entry, "severity"),
|
||||
action,
|
||||
description);
|
||||
|
||||
var details = new Dictionary<string, object?>(StringComparerOrdinal)
|
||||
{
|
||||
["contentHash"] = GetString(entry, "contentHash"),
|
||||
["sequenceNumber"] = GetString(entry, "sequenceNumber"),
|
||||
["httpMethod"] = GetString(entry, "httpMethod"),
|
||||
["requestPath"] = GetString(entry, "requestPath"),
|
||||
["metadata"] = GetString(entry, "metadata")
|
||||
};
|
||||
|
||||
var oldState = GetString(entry, "oldState");
|
||||
var newState = GetString(entry, "newState");
|
||||
if (!string.IsNullOrWhiteSpace(oldState) || !string.IsNullOrWhiteSpace(newState))
|
||||
{
|
||||
details["oldState"] = oldState;
|
||||
details["newState"] = newState;
|
||||
}
|
||||
|
||||
events.Add(new UnifiedAuditEvent
|
||||
{
|
||||
Id = entryId,
|
||||
Timestamp = occurredAt,
|
||||
Module = "jobengine",
|
||||
Action = action,
|
||||
Severity = severity,
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = actorId,
|
||||
Name = actorId,
|
||||
Type = actorType,
|
||||
IpAddress = GetString(entry, "actorIp"),
|
||||
UserAgent = GetString(entry, "userAgent")
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = resourceType,
|
||||
Id = resourceId
|
||||
},
|
||||
Description = description,
|
||||
Details = details,
|
||||
Diff = !string.IsNullOrWhiteSpace(oldState) || !string.IsNullOrWhiteSpace(newState)
|
||||
? new UnifiedAuditDiff
|
||||
{
|
||||
Before = oldState,
|
||||
After = newState,
|
||||
Fields = ["state"]
|
||||
}
|
||||
: null,
|
||||
CorrelationId = GetString(entry, "correlationId"),
|
||||
TenantId = GetString(entry, "tenantId"),
|
||||
Tags = ["jobengine", action]
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetPolicyEventsAsync(
|
||||
UnifiedAuditModuleEndpointsOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
options.PolicyBaseUrl,
|
||||
"/api/v1/governance/audit/events",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["page"] = "1",
|
||||
["pageSize"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
|
||||
if (uri is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(document.RootElement, "events", out var entries) ||
|
||||
entries.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
var events = new List<UnifiedAuditEvent>();
|
||||
foreach (var entry in entries.EnumerateArray())
|
||||
{
|
||||
var id = GetString(entry, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = GetString(entry, "type");
|
||||
var summary = GetString(entry, "summary") ?? "Policy governance audit event";
|
||||
var action = UnifiedAuditValueMapper.NormalizeAction(type, summary);
|
||||
|
||||
events.Add(new UnifiedAuditEvent
|
||||
{
|
||||
Id = id,
|
||||
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "timestamp"), TimestampFallback),
|
||||
Module = "policy",
|
||||
Action = action,
|
||||
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, summary),
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = GetString(entry, "actor") ?? "policy-system",
|
||||
Name = GetString(entry, "actor") ?? "policy-system",
|
||||
Type = UnifiedAuditValueMapper.NormalizeActorType(GetString(entry, "actorType"))
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = GetString(entry, "targetResourceType") ?? "policy_resource",
|
||||
Id = GetString(entry, "targetResource") ?? id
|
||||
},
|
||||
Description = summary,
|
||||
Details = new Dictionary<string, object?>(StringComparerOrdinal)
|
||||
{
|
||||
["eventType"] = type
|
||||
},
|
||||
CorrelationId = GetString(entry, "correlationId"),
|
||||
TenantId = GetString(entry, "tenantId"),
|
||||
Tags = ["policy", action]
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetEvidenceLockerEventsAsync(
|
||||
UnifiedAuditModuleEndpointsOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
options.EvidenceLockerBaseUrl,
|
||||
"/api/v1/evidence/audit",
|
||||
new Dictionary<string, string?> { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) });
|
||||
|
||||
if (uri is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
if (!TryGetPropertyIgnoreCase(document.RootElement, "items", out var entries) ||
|
||||
entries.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
var events = new List<UnifiedAuditEvent>();
|
||||
foreach (var entry in entries.EnumerateArray())
|
||||
{
|
||||
var id = GetString(entry, "eventId");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var eventType = GetString(entry, "eventType");
|
||||
var action = UnifiedAuditValueMapper.NormalizeAction(eventType);
|
||||
var subject = GetString(entry, "subject") ?? id;
|
||||
|
||||
events.Add(new UnifiedAuditEvent
|
||||
{
|
||||
Id = id,
|
||||
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "occurredAt"), TimestampFallback),
|
||||
Module = "sbom",
|
||||
Action = action,
|
||||
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, eventType),
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "evidencelocker-system",
|
||||
Name = "evidencelocker-system",
|
||||
Type = "system"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = InferSubjectType(subject),
|
||||
Id = subject
|
||||
},
|
||||
Description = eventType ?? "EvidenceLocker audit event",
|
||||
Details = new Dictionary<string, object?>(StringComparerOrdinal)
|
||||
{
|
||||
["eventType"] = eventType,
|
||||
["subject"] = subject
|
||||
},
|
||||
Tags = ["sbom", action]
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetNotifyEventsAsync(
|
||||
UnifiedAuditModuleEndpointsOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = BuildUri(
|
||||
options.NotifyBaseUrl,
|
||||
"/api/v1/notify/audit",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture),
|
||||
["offset"] = "0"
|
||||
});
|
||||
|
||||
if (uri is null)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null || document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditEvent>();
|
||||
}
|
||||
|
||||
var events = new List<UnifiedAuditEvent>();
|
||||
foreach (var entry in document.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = GetString(entry, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var action = UnifiedAuditValueMapper.NormalizeAction(GetString(entry, "action"));
|
||||
var description = GetString(entry, "action") ?? "Notify audit event";
|
||||
var details = ReadDetails(entry, "details");
|
||||
|
||||
events.Add(new UnifiedAuditEvent
|
||||
{
|
||||
Id = id,
|
||||
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "createdAt"), TimestampFallback),
|
||||
Module = "integrations",
|
||||
Action = action,
|
||||
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, description),
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = GetString(entry, "userId") ?? "notify-system",
|
||||
Name = GetString(entry, "userId") ?? "notify-system",
|
||||
Type = "service"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = GetString(entry, "resourceType") ?? "notify_resource",
|
||||
Id = GetString(entry, "resourceId") ?? id
|
||||
},
|
||||
Description = description,
|
||||
Details = details,
|
||||
CorrelationId = GetString(entry, "correlationId"),
|
||||
TenantId = GetString(entry, "tenantId"),
|
||||
Tags = ["integrations", action]
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private async Task<JsonDocument?> GetJsonDocumentAsync(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ClientName);
|
||||
using var response = await client.GetAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Unified audit module fetch returned HTTP {StatusCode} for {Uri}",
|
||||
(int)response.StatusCode,
|
||||
uri);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Unified audit module fetch timed out for {Uri}", uri);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unified audit module fetch failed for {Uri}", uri);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> ReadDetails(JsonElement parent, string propertyName)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(parent, propertyName, out var detailsElement) ||
|
||||
detailsElement.ValueKind == JsonValueKind.Null ||
|
||||
detailsElement.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparerOrdinal);
|
||||
}
|
||||
|
||||
if (detailsElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return ConvertToDictionary(detailsElement);
|
||||
}
|
||||
|
||||
if (detailsElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = detailsElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparerOrdinal);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(raw);
|
||||
if (parsed.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return ConvertToDictionary(parsed.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Intentionally ignored: keep details as a plain string.
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>(StringComparerOrdinal)
|
||||
{
|
||||
[propertyName] = raw
|
||||
};
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>(StringComparerOrdinal)
|
||||
{
|
||||
[propertyName] = ConvertValue(detailsElement)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ConvertToDictionary(JsonElement element)
|
||||
{
|
||||
var dictionary = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
dictionary[property.Name] = ConvertValue(property.Value);
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static object? ConvertValue(JsonElement value)
|
||||
{
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number when value.TryGetInt64(out var int64Value) => int64Value,
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,
|
||||
JsonValueKind.Number => value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => value.GetRawText(),
|
||||
JsonValueKind.Object => value.GetRawText(),
|
||||
_ => value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferSubjectType(string subject)
|
||||
{
|
||||
if (subject.StartsWith("pack-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "evidence_pack";
|
||||
}
|
||||
|
||||
if (subject.StartsWith("run-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "run";
|
||||
}
|
||||
|
||||
return "subject";
|
||||
}
|
||||
|
||||
private static Uri? BuildUri(string baseUrl, string path, IDictionary<string, string?> query)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endpoint = new Uri(baseUri, path);
|
||||
var filtered = query
|
||||
.Where(pair => !string.IsNullOrWhiteSpace(pair.Value))
|
||||
.ToDictionary(pair => pair.Key, pair => (string?)pair.Value, StringComparer.Ordinal);
|
||||
|
||||
var value = filtered.Count == 0
|
||||
? endpoint.ToString()
|
||||
: QueryHelpers.AddQueryString(endpoint.ToString(), filtered);
|
||||
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(element, propertyName, out var value) ||
|
||||
value.ValueKind == JsonValueKind.Null ||
|
||||
value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
JsonValueKind.True => bool.TrueString.ToLowerInvariant(),
|
||||
JsonValueKind.False => bool.FalseString.ToLowerInvariant(),
|
||||
_ => value.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (propertyName.Equals(property.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
public interface IUnifiedAuditAggregationService
|
||||
{
|
||||
Task<UnifiedAuditEventsPagedResponse> GetEventsAsync(
|
||||
UnifiedAuditQuery query,
|
||||
string? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditEvent?> GetEventByIdAsync(
|
||||
string eventId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditStatsSummary> GetStatsAsync(
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<UnifiedAuditTimelineEntry>> SearchTimelineAsync(
|
||||
string query,
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<UnifiedAuditCorrelationCluster>> GetCorrelationsAsync(
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditCorrelationCluster?> GetCorrelationAsync(
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<UnifiedAuditAnomalyAlert>> GetAnomaliesAsync(
|
||||
bool? acknowledged,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditAnomalyAlert?> AcknowledgeAnomalyAsync(
|
||||
string alertId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditExportResponse> RequestExportAsync(
|
||||
UnifiedAuditExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<UnifiedAuditExportResponse?> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
public interface IUnifiedAuditEventProvider
|
||||
{
|
||||
Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
public sealed class UnifiedAuditAggregationService : IUnifiedAuditAggregationService
|
||||
{
|
||||
private static readonly StringComparer StringComparerOrdinal = StringComparer.Ordinal;
|
||||
private static readonly StringComparer StringComparerOrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly IUnifiedAuditEventProvider _eventProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<UnifiedAuditAggregationService> _logger;
|
||||
private readonly ConcurrentDictionary<string, AcknowledgementState> _acknowledgedAlerts =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, UnifiedAuditExportResponse> _exports =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
private long _exportSequence;
|
||||
|
||||
public UnifiedAuditAggregationService(
|
||||
IUnifiedAuditEventProvider eventProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<UnifiedAuditAggregationService> logger)
|
||||
{
|
||||
_eventProvider = eventProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditEventsPagedResponse> GetEventsAsync(
|
||||
UnifiedAuditQuery query,
|
||||
string? cursor,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clampedLimit = Math.Clamp(limit, 1, 200);
|
||||
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var startIndex = ResolveStartIndex(events, cursor);
|
||||
var page = events.Skip(startIndex).Take(clampedLimit).ToList();
|
||||
var hasMore = startIndex + page.Count < events.Count;
|
||||
|
||||
return new UnifiedAuditEventsPagedResponse
|
||||
{
|
||||
Items = page,
|
||||
Cursor = hasMore && page.Count > 0 ? page[^1].Id : null,
|
||||
HasMore = hasMore,
|
||||
TotalCount = events.Count
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditEvent?> GetEventByIdAsync(
|
||||
string eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var events = await GetFilteredEventsAsync(new UnifiedAuditQuery(), cancellationToken).ConfigureAwait(false);
|
||||
return events.FirstOrDefault(e => e.Id.Equals(eventId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditStatsSummary> GetStatsAsync(
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var query = new UnifiedAuditQuery
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
};
|
||||
|
||||
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var byModule = CreateCountBuckets(UnifiedAuditCatalog.Modules);
|
||||
var byAction = CreateCountBuckets(UnifiedAuditCatalog.Actions);
|
||||
var bySeverity = CreateCountBuckets(UnifiedAuditCatalog.Severities);
|
||||
|
||||
foreach (var auditEvent in events)
|
||||
{
|
||||
byModule[auditEvent.Module] = byModule.GetValueOrDefault(auditEvent.Module) + 1;
|
||||
byAction[auditEvent.Action] = byAction.GetValueOrDefault(auditEvent.Action) + 1;
|
||||
bySeverity[auditEvent.Severity] = bySeverity.GetValueOrDefault(auditEvent.Severity) + 1;
|
||||
}
|
||||
|
||||
var topActors = events
|
||||
.GroupBy(
|
||||
e => $"{e.Actor.Id}|{e.Actor.Name}|{e.Actor.Type}",
|
||||
StringComparerOrdinalIgnoreCase)
|
||||
.Select(group => new UnifiedAuditActorStats
|
||||
{
|
||||
Actor = group.First().Actor,
|
||||
EventCount = group.LongCount()
|
||||
})
|
||||
.OrderByDescending(entry => entry.EventCount)
|
||||
.ThenBy(entry => entry.Actor.Id, StringComparerOrdinal)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var topResources = events
|
||||
.GroupBy(
|
||||
e => $"{e.Resource.Type}|{e.Resource.Id}|{e.Resource.Name}",
|
||||
StringComparerOrdinalIgnoreCase)
|
||||
.Select(group => new UnifiedAuditResourceStats
|
||||
{
|
||||
Resource = group.First().Resource,
|
||||
EventCount = group.LongCount()
|
||||
})
|
||||
.OrderByDescending(entry => entry.EventCount)
|
||||
.ThenBy(entry => entry.Resource.Type, StringComparerOrdinal)
|
||||
.ThenBy(entry => entry.Resource.Id, StringComparerOrdinal)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return new UnifiedAuditStatsSummary
|
||||
{
|
||||
Period = new UnifiedAuditStatsPeriod
|
||||
{
|
||||
Start = startDate ?? events.LastOrDefault()?.Timestamp ?? now.AddDays(-7),
|
||||
End = endDate ?? events.FirstOrDefault()?.Timestamp ?? now
|
||||
},
|
||||
TotalEvents = events.Count,
|
||||
ByModule = byModule,
|
||||
ByAction = byAction,
|
||||
BySeverity = bySeverity,
|
||||
TopActors = topActors,
|
||||
TopResources = topResources
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedAuditTimelineEntry>> SearchTimelineAsync(
|
||||
string query,
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<UnifiedAuditTimelineEntry>();
|
||||
}
|
||||
|
||||
var filtered = await GetFilteredEventsAsync(
|
||||
new UnifiedAuditQuery
|
||||
{
|
||||
Search = query,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var clampedLimit = Math.Clamp(limit, 1, 200);
|
||||
var grouped = filtered
|
||||
.GroupBy(e => DateTimeOffset.FromUnixTimeSeconds(e.Timestamp.ToUnixTimeSeconds()))
|
||||
.OrderByDescending(group => group.Key)
|
||||
.Take(clampedLimit)
|
||||
.Select(group =>
|
||||
{
|
||||
var orderedEvents = group
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ThenBy(e => e.Id, StringComparerOrdinal)
|
||||
.ToList();
|
||||
|
||||
var clusterId = orderedEvents
|
||||
.Select(e => e.CorrelationId)
|
||||
.FirstOrDefault(id => !string.IsNullOrWhiteSpace(id));
|
||||
|
||||
return new UnifiedAuditTimelineEntry
|
||||
{
|
||||
Timestamp = group.Key,
|
||||
Events = orderedEvents,
|
||||
ClusterId = clusterId,
|
||||
ClusterSize = orderedEvents.Count > 1 ? orderedEvents.Count : null
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedAuditCorrelationCluster>> GetCorrelationsAsync(
|
||||
DateTimeOffset? startDate,
|
||||
DateTimeOffset? endDate,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = await GetFilteredEventsAsync(
|
||||
new UnifiedAuditQuery
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var clusters = events
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.CorrelationId))
|
||||
.GroupBy(e => e.CorrelationId!, StringComparerOrdinalIgnoreCase)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(BuildCorrelationCluster)
|
||||
.OrderByDescending(cluster => cluster.RootEvent.Timestamp)
|
||||
.ThenBy(cluster => cluster.CorrelationId, StringComparerOrdinal)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.ToList();
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditCorrelationCluster?> GetCorrelationAsync(
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var events = await GetFilteredEventsAsync(
|
||||
new UnifiedAuditQuery { CorrelationId = correlationId },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildCorrelationCluster(events.GroupBy(e => correlationId, StringComparerOrdinalIgnoreCase).Single());
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedAuditAnomalyAlert>> GetAnomaliesAsync(
|
||||
bool? acknowledged,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var events = await GetFilteredEventsAsync(
|
||||
new UnifiedAuditQuery
|
||||
{
|
||||
StartDate = now.AddDays(-7),
|
||||
EndDate = now
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var anomalies = BuildAnomalies(events, now)
|
||||
.Select(ApplyAcknowledgement)
|
||||
.Where(alert => !acknowledged.HasValue || alert.Acknowledged == acknowledged.Value)
|
||||
.OrderByDescending(alert => alert.DetectedAt)
|
||||
.ThenBy(alert => alert.Id, StringComparerOrdinal)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.ToList();
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditAnomalyAlert?> AcknowledgeAnomalyAsync(
|
||||
string alertId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(alertId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var alerts = await GetAnomaliesAsync(null, 500, cancellationToken).ConfigureAwait(false);
|
||||
var target = alerts.FirstOrDefault(alert => alert.Id.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
if (target is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var acknowledgedAt = _timeProvider.GetUtcNow();
|
||||
_acknowledgedAlerts[alertId] = new AcknowledgementState(acknowledgedBy, acknowledgedAt);
|
||||
|
||||
return target with
|
||||
{
|
||||
Acknowledged = true,
|
||||
AcknowledgedBy = acknowledgedBy,
|
||||
AcknowledgedAt = acknowledgedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<UnifiedAuditExportResponse> RequestExportAsync(
|
||||
UnifiedAuditExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var query = BuildQueryFromFilters(request.Filters);
|
||||
var events = await GetFilteredEventsAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sequence = Interlocked.Increment(ref _exportSequence);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var response = new UnifiedAuditExportResponse
|
||||
{
|
||||
ExportId = $"audit-export-{sequence:D6}",
|
||||
Status = "completed",
|
||||
EventCount = events.Count,
|
||||
CreatedAt = now,
|
||||
CompletedAt = now,
|
||||
ExpiresAt = now.AddDays(1)
|
||||
};
|
||||
|
||||
_exports[response.ExportId] = response;
|
||||
return response;
|
||||
}
|
||||
|
||||
public Task<UnifiedAuditExportResponse?> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_exports.TryGetValue(exportId, out var response) ? response : null);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetFilteredEventsAsync(
|
||||
UnifiedAuditQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = await _eventProvider.GetEventsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var filtered = events
|
||||
.Where(auditEvent => MatchesQuery(auditEvent, query))
|
||||
.OrderByDescending(auditEvent => auditEvent.Timestamp)
|
||||
.ThenBy(auditEvent => auditEvent.Id, StringComparerOrdinal)
|
||||
.ThenBy(auditEvent => auditEvent.Module, StringComparerOrdinal)
|
||||
.ThenBy(auditEvent => auditEvent.Action, StringComparerOrdinal)
|
||||
.ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static bool MatchesQuery(UnifiedAuditEvent auditEvent, UnifiedAuditQuery query)
|
||||
{
|
||||
if (query.Modules is { Count: > 0 } &&
|
||||
!query.Modules.Contains(auditEvent.Module))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.Actions is { Count: > 0 } &&
|
||||
!query.Actions.Contains(auditEvent.Action))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.Severities is { Count: > 0 } &&
|
||||
!query.Severities.Contains(auditEvent.Severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ActorId) &&
|
||||
!auditEvent.Actor.Id.Equals(query.ActorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ActorName))
|
||||
{
|
||||
var actorNameMatch =
|
||||
ContainsIgnoreCase(auditEvent.Actor.Name, query.ActorName) ||
|
||||
ContainsIgnoreCase(auditEvent.Actor.Email, query.ActorName) ||
|
||||
ContainsIgnoreCase(auditEvent.Actor.Id, query.ActorName);
|
||||
if (!actorNameMatch)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ResourceType) &&
|
||||
!auditEvent.Resource.Type.Equals(query.ResourceType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ResourceId) &&
|
||||
!auditEvent.Resource.Id.Equals(query.ResourceId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.StartDate.HasValue && auditEvent.Timestamp < query.StartDate.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.EndDate.HasValue && auditEvent.Timestamp > query.EndDate.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.CorrelationId) &&
|
||||
!string.Equals(auditEvent.CorrelationId, query.CorrelationId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId) &&
|
||||
!string.Equals(auditEvent.TenantId, query.TenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.Tags is { Count: > 0 } &&
|
||||
!auditEvent.Tags.Any(tag => query.Tags.Contains(tag)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search) && !MatchesSearch(auditEvent, query.Search))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? source, string expected) =>
|
||||
!string.IsNullOrWhiteSpace(source) &&
|
||||
source.Contains(expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool MatchesSearch(UnifiedAuditEvent auditEvent, string search)
|
||||
{
|
||||
if (ContainsIgnoreCase(auditEvent.Id, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Description, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Module, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Action, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Severity, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Actor.Id, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Actor.Name, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Actor.Email, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Resource.Type, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Resource.Id, search) ||
|
||||
ContainsIgnoreCase(auditEvent.Resource.Name, search) ||
|
||||
ContainsIgnoreCase(auditEvent.CorrelationId, search) ||
|
||||
ContainsIgnoreCase(auditEvent.ParentEventId, search) ||
|
||||
ContainsIgnoreCase(auditEvent.TenantId, search))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (auditEvent.Tags.Any(tag => ContainsIgnoreCase(tag, search)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return auditEvent.Details.Values.Any(value => ContainsIgnoreCase(value?.ToString(), search));
|
||||
}
|
||||
|
||||
private static int ResolveStartIndex(IReadOnlyList<UnifiedAuditEvent> events, string? cursor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var index = events
|
||||
.Select((auditEvent, eventIndex) => new { auditEvent.Id, eventIndex })
|
||||
.FirstOrDefault(item => item.Id.Equals(cursor, StringComparison.OrdinalIgnoreCase))
|
||||
?.eventIndex;
|
||||
|
||||
return index.HasValue ? index.Value + 1 : 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, long> CreateCountBuckets(IReadOnlyList<string> keys)
|
||||
{
|
||||
var dictionary = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
dictionary[key] = 0;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private UnifiedAuditCorrelationCluster BuildCorrelationCluster(
|
||||
IGrouping<string, UnifiedAuditEvent> correlationGroup)
|
||||
{
|
||||
var ordered = correlationGroup
|
||||
.OrderBy(auditEvent => auditEvent.Timestamp)
|
||||
.ThenBy(auditEvent => auditEvent.Id, StringComparerOrdinal)
|
||||
.ToList();
|
||||
|
||||
var rootEvent = ordered[0];
|
||||
var lastEvent = ordered[^1];
|
||||
var duration = Math.Max(
|
||||
0,
|
||||
(long)(lastEvent.Timestamp - rootEvent.Timestamp).TotalMilliseconds);
|
||||
|
||||
var outcome = ordered.Any(e => e.Severity is "critical" or "error")
|
||||
? "failure"
|
||||
: ordered.Any(e => e.Severity == "warning")
|
||||
? "partial"
|
||||
: "success";
|
||||
|
||||
return new UnifiedAuditCorrelationCluster
|
||||
{
|
||||
CorrelationId = correlationGroup.Key,
|
||||
RootEvent = rootEvent,
|
||||
RelatedEvents = ordered.Skip(1).ToList(),
|
||||
Duration = duration,
|
||||
Outcome = outcome
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<UnifiedAuditAnomalyAlert> BuildAnomalies(
|
||||
IReadOnlyList<UnifiedAuditEvent> events,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return Array.Empty<UnifiedAuditAnomalyAlert>();
|
||||
}
|
||||
|
||||
var anomalies = new List<UnifiedAuditAnomalyAlert>();
|
||||
|
||||
var dominantModule = events
|
||||
.GroupBy(e => e.Module, StringComparerOrdinalIgnoreCase)
|
||||
.Select(group => new { Module = group.Key, Count = group.Count() })
|
||||
.OrderByDescending(item => item.Count)
|
||||
.ThenBy(item => item.Module, StringComparerOrdinal)
|
||||
.First();
|
||||
|
||||
if (dominantModule.Count >= Math.Max(10, (int)Math.Ceiling(events.Count * 0.5)))
|
||||
{
|
||||
var affected = events
|
||||
.Where(e => e.Module.Equals(dominantModule.Module, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(20)
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = $"alert-unusual-volume-{dominantModule.Module}".ToLowerInvariant(),
|
||||
DetectedAt = events.First(e => e.Module.Equals(dominantModule.Module, StringComparison.OrdinalIgnoreCase)).Timestamp,
|
||||
Type = "unusual_volume",
|
||||
Severity = "warning",
|
||||
Description = $"High audit volume detected for module '{dominantModule.Module}' in the last 7 days.",
|
||||
AffectedEvents = affected,
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
var failedAuthEvents = events
|
||||
.Where(e =>
|
||||
e.Action == "fail" &&
|
||||
(e.Module == "authority" ||
|
||||
ContainsIgnoreCase(e.Description, "auth") ||
|
||||
ContainsIgnoreCase(e.Resource.Type, "token")))
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
if (failedAuthEvents.Count >= 3)
|
||||
{
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = "alert-failed-auth-spike",
|
||||
DetectedAt = failedAuthEvents.First().Timestamp,
|
||||
Type = "failed_auth_spike",
|
||||
Severity = "error",
|
||||
Description = "Spike in authentication-related failures detected.",
|
||||
AffectedEvents = failedAuthEvents.Select(e => e.Id).ToList(),
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
var privilegeEvents = events
|
||||
.Where(e => e.Actor.Type == "user" && e.Action is "promote" or "approve" or "enable" or "issue" or "rotate")
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
if (privilegeEvents.Count >= 3)
|
||||
{
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = "alert-privilege-escalation",
|
||||
DetectedAt = privilegeEvents.First().Timestamp,
|
||||
Type = "privilege_escalation",
|
||||
Severity = "warning",
|
||||
Description = "Multiple privilege-affecting actions were detected in a short interval.",
|
||||
AffectedEvents = privilegeEvents.Select(e => e.Id).ToList(),
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
var offHoursEvents = events
|
||||
.Where(e => e.Timestamp.UtcDateTime.Hour is < 6 or >= 22)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
if (offHoursEvents.Count >= 3)
|
||||
{
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = "alert-off-hours-activity",
|
||||
DetectedAt = offHoursEvents.First().Timestamp,
|
||||
Type = "off_hours_activity",
|
||||
Severity = "warning",
|
||||
Description = "Sensitive operations were performed during off-hours.",
|
||||
AffectedEvents = offHoursEvents.Select(e => e.Id).ToList(),
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
var unusualPatternEvents = events
|
||||
.Where(e => e.Severity is "error" or "critical")
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
if (unusualPatternEvents.Count >= 2)
|
||||
{
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = "alert-unusual-pattern",
|
||||
DetectedAt = unusualPatternEvents.First().Timestamp,
|
||||
Type = "unusual_pattern",
|
||||
Severity = "error",
|
||||
Description = "A burst of high-severity audit events indicates an unusual pattern.",
|
||||
AffectedEvents = unusualPatternEvents.Select(e => e.Id).ToList(),
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
if (anomalies.Count == 0)
|
||||
{
|
||||
anomalies.Add(new UnifiedAuditAnomalyAlert
|
||||
{
|
||||
Id = "alert-baseline-unusual-pattern",
|
||||
DetectedAt = events.FirstOrDefault()?.Timestamp ?? now,
|
||||
Type = "unusual_pattern",
|
||||
Severity = "info",
|
||||
Description = "Baseline anomaly monitor is active with no elevated risk findings.",
|
||||
AffectedEvents = events.Take(5).Select(e => e.Id).ToList(),
|
||||
Acknowledged = false
|
||||
});
|
||||
}
|
||||
|
||||
return anomalies;
|
||||
}
|
||||
|
||||
private UnifiedAuditAnomalyAlert ApplyAcknowledgement(UnifiedAuditAnomalyAlert alert)
|
||||
{
|
||||
if (!_acknowledgedAlerts.TryGetValue(alert.Id, out var state))
|
||||
{
|
||||
return alert;
|
||||
}
|
||||
|
||||
return alert with
|
||||
{
|
||||
Acknowledged = true,
|
||||
AcknowledgedBy = state.AcknowledgedBy,
|
||||
AcknowledgedAt = state.AcknowledgedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static UnifiedAuditQuery BuildQueryFromFilters(UnifiedAuditLogFilters filters)
|
||||
{
|
||||
return new UnifiedAuditQuery
|
||||
{
|
||||
Modules = NormalizeSet(filters.Modules, UnifiedAuditCatalog.Modules),
|
||||
Actions = NormalizeSet(filters.Actions, UnifiedAuditCatalog.Actions),
|
||||
Severities = NormalizeSet(filters.Severities, UnifiedAuditCatalog.Severities),
|
||||
ActorId = NormalizeText(filters.ActorId),
|
||||
ActorName = NormalizeText(filters.ActorName),
|
||||
ResourceType = NormalizeText(filters.ResourceType),
|
||||
ResourceId = NormalizeText(filters.ResourceId),
|
||||
StartDate = ParseDate(filters.StartDate),
|
||||
EndDate = ParseDate(filters.EndDate),
|
||||
Search = NormalizeText(filters.Search),
|
||||
CorrelationId = NormalizeText(filters.CorrelationId),
|
||||
TenantId = NormalizeText(filters.TenantId),
|
||||
Tags = NormalizeSet(filters.Tags, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string>? NormalizeSet(
|
||||
IReadOnlyList<string>? values,
|
||||
IReadOnlyList<string>? allowedValues)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = NormalizeText(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowedValues is not null &&
|
||||
!allowedValues.Contains(normalized, StringComparerOrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(normalized);
|
||||
}
|
||||
|
||||
return set.Count > 0 ? set : null;
|
||||
}
|
||||
|
||||
private static string? NormalizeText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static DateTimeOffset? ParseDate(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
raw,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record AcknowledgementState(string AcknowledgedBy, DateTimeOffset AcknowledgedAt);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Audit;
|
||||
|
||||
public static class UnifiedAuditCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Modules =
|
||||
[
|
||||
"authority",
|
||||
"policy",
|
||||
"jobengine",
|
||||
"integrations",
|
||||
"vex",
|
||||
"scanner",
|
||||
"attestor",
|
||||
"sbom",
|
||||
"scheduler"
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> Actions =
|
||||
[
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"promote",
|
||||
"demote",
|
||||
"revoke",
|
||||
"issue",
|
||||
"refresh",
|
||||
"test",
|
||||
"fail",
|
||||
"complete",
|
||||
"start",
|
||||
"submit",
|
||||
"approve",
|
||||
"reject",
|
||||
"sign",
|
||||
"verify",
|
||||
"rotate",
|
||||
"enable",
|
||||
"disable",
|
||||
"deadletter",
|
||||
"replay"
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> Severities =
|
||||
[
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical"
|
||||
];
|
||||
}
|
||||
|
||||
public static class UnifiedAuditValueMapper
|
||||
{
|
||||
public static string NormalizeAction(string? rawAction, string? description = null)
|
||||
{
|
||||
var source = (rawAction ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
source = (description ?? string.Empty).Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
foreach (var action in UnifiedAuditCatalog.Actions)
|
||||
{
|
||||
if (source.Contains(action, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
if (source.Contains("created", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "create";
|
||||
}
|
||||
|
||||
if (source.Contains("updated", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("changed", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("modified", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "update";
|
||||
}
|
||||
|
||||
if (source.Contains("deleted", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("removed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "delete";
|
||||
}
|
||||
|
||||
if (source.Contains("approved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "approve";
|
||||
}
|
||||
|
||||
if (source.Contains("rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "reject";
|
||||
}
|
||||
|
||||
if (source.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("failure", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "fail";
|
||||
}
|
||||
|
||||
if (source.Contains("started", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("begin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "start";
|
||||
}
|
||||
|
||||
if (source.Contains("completed", StringComparison.OrdinalIgnoreCase) ||
|
||||
source.Contains("finished", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "complete";
|
||||
}
|
||||
|
||||
return "update";
|
||||
}
|
||||
|
||||
public static string NormalizeSeverity(string? rawSeverity, string action, string? description = null)
|
||||
{
|
||||
var source = (rawSeverity ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (UnifiedAuditCatalog.Severities.Contains(source, StringComparer.Ordinal))
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var text = (description ?? string.Empty).ToLowerInvariant();
|
||||
if (text.Contains("critical", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "critical";
|
||||
}
|
||||
|
||||
return action switch
|
||||
{
|
||||
"fail" or "reject" or "revoke" or "delete" or "deadletter" => "error",
|
||||
"demote" or "disable" => "warning",
|
||||
_ => "info",
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeActorType(string? rawType)
|
||||
{
|
||||
var source = (rawType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return source switch
|
||||
{
|
||||
"user" => "user",
|
||||
"system" => "system",
|
||||
"service" => "service",
|
||||
"automation" => "automation",
|
||||
_ => "system"
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeModule(string rawModule)
|
||||
{
|
||||
var source = rawModule.Trim().ToLowerInvariant();
|
||||
return source switch
|
||||
{
|
||||
"evidencelocker" => "sbom",
|
||||
"notify" => "integrations",
|
||||
_ => UnifiedAuditCatalog.Modules.Contains(source, StringComparer.Ordinal) ? source : "integrations"
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTimeOffset ParseTimestampOrDefault(string? rawTimestamp, DateTimeOffset defaultValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawTimestamp))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
rawTimestamp,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditActor
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public IReadOnlyList<string>? Scopes { get; init; }
|
||||
public string? IpAddress { get; init; }
|
||||
public string? UserAgent { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditResource
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public IReadOnlyDictionary<string, object?>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditDiff
|
||||
{
|
||||
public object? Before { get; init; }
|
||||
public object? After { get; init; }
|
||||
public required IReadOnlyList<string> Fields { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditEvent
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required string Module { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required UnifiedAuditActor Actor { get; init; }
|
||||
public required UnifiedAuditResource Resource { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required IReadOnlyDictionary<string, object?> Details { get; init; }
|
||||
public UnifiedAuditDiff? Diff { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? ParentEventId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public required IReadOnlyList<string> Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditEventsPagedResponse
|
||||
{
|
||||
public required IReadOnlyList<UnifiedAuditEvent> Items { get; init; }
|
||||
public string? Cursor { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditLogFilters
|
||||
{
|
||||
public IReadOnlyList<string>? Modules { get; init; }
|
||||
public IReadOnlyList<string>? Actions { get; init; }
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
public string? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? ResourceType { get; init; }
|
||||
public string? ResourceId { get; init; }
|
||||
public string? StartDate { get; init; }
|
||||
public string? EndDate { get; init; }
|
||||
public string? Search { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditExportRequest
|
||||
{
|
||||
public required UnifiedAuditLogFilters Filters { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public bool IncludeDetails { get; init; }
|
||||
public bool IncludeDiffs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditExportResponse
|
||||
{
|
||||
public required string ExportId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? DownloadUrl { get; init; }
|
||||
public int? EventCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditStatsSummary
|
||||
{
|
||||
public required UnifiedAuditStatsPeriod Period { get; init; }
|
||||
public long TotalEvents { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> ByModule { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> ByAction { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> BySeverity { get; init; }
|
||||
public required IReadOnlyList<UnifiedAuditActorStats> TopActors { get; init; }
|
||||
public required IReadOnlyList<UnifiedAuditResourceStats> TopResources { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditStatsPeriod
|
||||
{
|
||||
public required DateTimeOffset Start { get; init; }
|
||||
public required DateTimeOffset End { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditActorStats
|
||||
{
|
||||
public required UnifiedAuditActor Actor { get; init; }
|
||||
public long EventCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditResourceStats
|
||||
{
|
||||
public required UnifiedAuditResource Resource { get; init; }
|
||||
public long EventCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditTimelineEntry
|
||||
{
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required IReadOnlyList<UnifiedAuditEvent> Events { get; init; }
|
||||
public string? ClusterId { get; init; }
|
||||
public int? ClusterSize { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditCorrelationCluster
|
||||
{
|
||||
public required string CorrelationId { get; init; }
|
||||
public required UnifiedAuditEvent RootEvent { get; init; }
|
||||
public required IReadOnlyList<UnifiedAuditEvent> RelatedEvents { get; init; }
|
||||
public long Duration { get; init; }
|
||||
public required string Outcome { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditAnomalyAlert
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required IReadOnlyList<string> AffectedEvents { get; init; }
|
||||
public bool Acknowledged { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditQuery
|
||||
{
|
||||
public HashSet<string>? Modules { get; init; }
|
||||
public HashSet<string>? Actions { get; init; }
|
||||
public HashSet<string>? Severities { get; init; }
|
||||
public string? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? ResourceType { get; init; }
|
||||
public string? ResourceId { get; init; }
|
||||
public DateTimeOffset? StartDate { get; init; }
|
||||
public DateTimeOffset? EndDate { get; init; }
|
||||
public string? Search { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public HashSet<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditModuleEndpointsOptions
|
||||
{
|
||||
public string JobEngineBaseUrl { get; set; } = "http://jobengine.stella-ops.local";
|
||||
public string PolicyBaseUrl { get; set; } = "http://policy-gateway.stella-ops.local";
|
||||
public string EvidenceLockerBaseUrl { get; set; } = "http://evidencelocker.stella-ops.local";
|
||||
public string NotifyBaseUrl { get; set; } = "http://notify.stella-ops.local";
|
||||
public int FetchLimitPerModule { get; set; } = 250;
|
||||
public int RequestTimeoutSeconds { get; set; } = 2;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Timeline.WebService.Audit;
|
||||
using StellaOps.Timeline.WebService.Security;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
public static class UnifiedAuditEndpoints
|
||||
{
|
||||
public static void MapUnifiedAuditEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/audit")
|
||||
.WithTags("Unified Audit")
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/events", GetEventsAsync)
|
||||
.WithName("GetUnifiedAuditEvents")
|
||||
.WithDescription("Get unified audit events with filtering and cursor pagination.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/events/{eventId}", GetEventByIdAsync)
|
||||
.WithName("GetUnifiedAuditEventById")
|
||||
.WithDescription("Get a single unified audit event by ID.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/stats", GetStatsAsync)
|
||||
.WithName("GetUnifiedAuditStats")
|
||||
.WithDescription("Get unified audit statistics summary.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/timeline/search", SearchTimelineAsync)
|
||||
.WithName("SearchUnifiedAuditTimeline")
|
||||
.WithDescription("Search unified audit timeline entries.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/correlations", GetCorrelationsAsync)
|
||||
.WithName("GetUnifiedAuditCorrelations")
|
||||
.WithDescription("Get unified audit correlation clusters.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/correlations/{correlationId}", GetCorrelationByIdAsync)
|
||||
.WithName("GetUnifiedAuditCorrelationById")
|
||||
.WithDescription("Get a unified audit correlation cluster by correlation ID.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapGet("/anomalies", GetAnomaliesAsync)
|
||||
.WithName("GetUnifiedAuditAnomalies")
|
||||
.WithDescription("Get anomaly alerts from unified audit events.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
|
||||
group.MapPost("/anomalies/{alertId}/acknowledge", AcknowledgeAnomalyAsync)
|
||||
.WithName("AcknowledgeUnifiedAuditAnomaly")
|
||||
.WithDescription("Acknowledge an anomaly alert.")
|
||||
.RequireAuthorization(TimelinePolicies.Write);
|
||||
|
||||
group.MapPost("/export", RequestExportAsync)
|
||||
.WithName("RequestUnifiedAuditExport")
|
||||
.WithDescription("Request a unified audit export.")
|
||||
.RequireAuthorization(TimelinePolicies.Write);
|
||||
|
||||
group.MapGet("/export/{exportId}", GetExportStatusAsync)
|
||||
.WithName("GetUnifiedAuditExportStatus")
|
||||
.WithDescription("Get unified audit export status.")
|
||||
.RequireAuthorization(TimelinePolicies.Read);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEventsAsync(
|
||||
[AsParameters] UnifiedAuditEventsRequest request,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = BuildQueryFromEventsRequest(request);
|
||||
var response = await service.GetEventsAsync(
|
||||
query,
|
||||
request.Cursor,
|
||||
request.Limit ?? 50,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEventByIdAsync(
|
||||
string eventId,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var eventItem = await service.GetEventByIdAsync(eventId, cancellationToken).ConfigureAwait(false);
|
||||
return eventItem is null ? Results.NotFound() : Results.Ok(eventItem);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStatsAsync(
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.GetStatsAsync(
|
||||
ParseDate(startDate),
|
||||
ParseDate(endDate),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchTimelineAsync(
|
||||
[FromQuery(Name = "q")] string? query,
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] int? limit,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.SearchTimelineAsync(
|
||||
query ?? string.Empty,
|
||||
ParseDate(startDate),
|
||||
ParseDate(endDate),
|
||||
limit ?? 100,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCorrelationsAsync(
|
||||
[FromQuery] string? startDate,
|
||||
[FromQuery] string? endDate,
|
||||
[FromQuery] int? limit,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.GetCorrelationsAsync(
|
||||
ParseDate(startDate),
|
||||
ParseDate(endDate),
|
||||
limit ?? 50,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCorrelationByIdAsync(
|
||||
string correlationId,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.GetCorrelationAsync(correlationId, cancellationToken).ConfigureAwait(false);
|
||||
return response is null ? Results.NotFound() : Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAnomaliesAsync(
|
||||
[FromQuery] bool? acknowledged,
|
||||
[FromQuery] int? limit,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.GetAnomaliesAsync(
|
||||
acknowledged,
|
||||
limit ?? 50,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcknowledgeAnomalyAsync(
|
||||
string alertId,
|
||||
ClaimsPrincipal user,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var acknowledgedBy = ResolveActor(user);
|
||||
var response = await service.AcknowledgeAnomalyAsync(
|
||||
alertId,
|
||||
acknowledgedBy,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response is null ? Results.NotFound() : Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestExportAsync(
|
||||
UnifiedAuditExportRequest request,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Filters is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "filters_required" });
|
||||
}
|
||||
|
||||
var response = await service.RequestExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExportStatusAsync(
|
||||
string exportId,
|
||||
IUnifiedAuditAggregationService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.GetExportStatusAsync(exportId, cancellationToken).ConfigureAwait(false);
|
||||
return response is null ? Results.NotFound() : Results.Ok(response);
|
||||
}
|
||||
|
||||
private static UnifiedAuditQuery BuildQueryFromEventsRequest(UnifiedAuditEventsRequest request)
|
||||
{
|
||||
return new UnifiedAuditQuery
|
||||
{
|
||||
Modules = ParseCsvSet(request.Modules, UnifiedAuditCatalog.Modules),
|
||||
Actions = ParseCsvSet(request.Actions, UnifiedAuditCatalog.Actions),
|
||||
Severities = ParseCsvSet(request.Severities, UnifiedAuditCatalog.Severities),
|
||||
ActorId = NormalizeText(request.ActorId),
|
||||
ActorName = NormalizeText(request.ActorName),
|
||||
ResourceType = NormalizeText(request.ResourceType),
|
||||
ResourceId = NormalizeText(request.ResourceId),
|
||||
StartDate = ParseDate(request.StartDate),
|
||||
EndDate = ParseDate(request.EndDate),
|
||||
Search = NormalizeText(request.Search),
|
||||
CorrelationId = NormalizeText(request.CorrelationId),
|
||||
TenantId = NormalizeText(request.TenantId),
|
||||
Tags = ParseCsvSet(request.Tags, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string>? ParseCsvSet(string? raw, IReadOnlyList<string>? allowedValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(value => value.Trim().ToLowerInvariant())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (allowedValues is not null)
|
||||
{
|
||||
values = values
|
||||
.Where(value => allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return values.Count == 0
|
||||
? null
|
||||
: new HashSet<string>(values, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDate(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
rawValue,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string ResolveActor(ClaimsPrincipal user)
|
||||
{
|
||||
return user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst("sub")?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "system";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UnifiedAuditEventsRequest
|
||||
{
|
||||
public string? Modules { get; init; }
|
||||
public string? Actions { get; init; }
|
||||
public string? Severities { get; init; }
|
||||
public string? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? ResourceType { get; init; }
|
||||
public string? ResourceId { get; init; }
|
||||
public string? StartDate { get; init; }
|
||||
public string? EndDate { get; init; }
|
||||
public string? Search { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public string? Tags { get; init; }
|
||||
public string? Cursor { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Eventing;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Timeline.Core;
|
||||
using StellaOps.Timeline.WebService.Audit;
|
||||
using StellaOps.Timeline.WebService.Endpoints;
|
||||
using StellaOps.Timeline.WebService.Security;
|
||||
|
||||
@@ -13,6 +14,45 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// Add services
|
||||
builder.Services.AddStellaOpsEventing(builder.Configuration);
|
||||
builder.Services.AddTimelineServices(builder.Configuration);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.Configure<UnifiedAuditModuleEndpointsOptions>(options =>
|
||||
{
|
||||
options.JobEngineBaseUrl = builder.Configuration["UnifiedAudit:Sources:JobEngine"]
|
||||
?? builder.Configuration["STELLAOPS_JOBENGINE_URL"]
|
||||
?? options.JobEngineBaseUrl;
|
||||
|
||||
options.PolicyBaseUrl = builder.Configuration["UnifiedAudit:Sources:Policy"]
|
||||
?? builder.Configuration["STELLAOPS_POLICY_GATEWAY_URL"]
|
||||
?? options.PolicyBaseUrl;
|
||||
|
||||
options.EvidenceLockerBaseUrl = builder.Configuration["UnifiedAudit:Sources:EvidenceLocker"]
|
||||
?? builder.Configuration["STELLAOPS_EVIDENCELOCKER_URL"]
|
||||
?? options.EvidenceLockerBaseUrl;
|
||||
|
||||
options.NotifyBaseUrl = builder.Configuration["UnifiedAudit:Sources:Notify"]
|
||||
?? builder.Configuration["STELLAOPS_NOTIFY_URL"]
|
||||
?? options.NotifyBaseUrl;
|
||||
|
||||
if (int.TryParse(builder.Configuration["UnifiedAudit:FetchLimitPerModule"], out var fetchLimit))
|
||||
{
|
||||
options.FetchLimitPerModule = fetchLimit;
|
||||
}
|
||||
|
||||
if (int.TryParse(builder.Configuration["UnifiedAudit:RequestTimeoutSeconds"], out var timeoutSeconds))
|
||||
{
|
||||
options.RequestTimeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient(HttpUnifiedAuditEventProvider.ClientName, (provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<UnifiedAuditModuleEndpointsOptions>>().Value;
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.RequestTimeoutSeconds));
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IUnifiedAuditEventProvider, HttpUnifiedAuditEventProvider>();
|
||||
builder.Services.AddSingleton<IUnifiedAuditAggregationService, UnifiedAuditAggregationService>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
@@ -73,6 +113,7 @@ await app.LoadTranslationsAsync();
|
||||
app.MapTimelineEndpoints();
|
||||
app.MapReplayEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapUnifiedAuditEndpoints();
|
||||
app.MapHealthEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
191
src/Timeline/StellaOps.TimelineIndexer.WebService/Program.cs
Normal file
191
src/Timeline/StellaOps.TimelineIndexer.WebService/Program.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.TimelineIndexer.WebService;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
|
||||
|
||||
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
|
||||
builder.Services.AddSingleton<IAuthEventSink, TimelineAuthorizationAuditSink>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configure: options =>
|
||||
{
|
||||
options.RequiredScopes.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddObservabilityResourcePolicies();
|
||||
options.DefaultPolicy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.TimelineRead }))
|
||||
.Build();
|
||||
options.FallbackPolicy = options.DefaultPolicy;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "timelineindexer",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("timelineindexer");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("timelineindexer");
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
await app.LoadTranslationsAsync();
|
||||
|
||||
MapTimelineEndpoints(app.MapGroup("/api/v1").RequireTenant(), routeNamePrefix: "timeline_api_v1");
|
||||
MapTimelineEndpoints(app.MapGroup(string.Empty).RequireTenant(), routeNamePrefix: "timeline");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
|
||||
static string GetTenantId(HttpContext ctx)
|
||||
{
|
||||
// Temporary: allow explicit header override; fallback to claim if present.
|
||||
if (ctx.Request.Headers.TryGetValue("X-Tenant", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header!;
|
||||
}
|
||||
|
||||
var tenant = ctx.User.FindFirst("tenant")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Tenant not provided");
|
||||
}
|
||||
|
||||
static void MapTimelineEndpoints(RouteGroupBuilder routes, string routeNamePrefix)
|
||||
{
|
||||
routes.MapGet("/timeline", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] string? source,
|
||||
[FromQuery] string? correlationId,
|
||||
[FromQuery] string? traceId,
|
||||
[FromQuery] string? severity,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromQuery] long? after,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var options = new TimelineQueryOptions
|
||||
{
|
||||
EventType = eventType,
|
||||
Source = source,
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
Severity = severity,
|
||||
Since = since,
|
||||
AfterEventSeq = after,
|
||||
Limit = limit ?? 100
|
||||
};
|
||||
var items = await service.QueryAsync(tenantId, options, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
})
|
||||
.WithName($"{routeNamePrefix}_query")
|
||||
.WithSummary("List timeline events")
|
||||
.WithDescription(_t("timelineindexer.timeline.query_description"))
|
||||
.WithTags("timeline")
|
||||
.Produces<IReadOnlyList<TimelineEventView>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
routes.MapGet("/timeline/{eventId}", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var item = await service.GetAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null ? Results.NotFound() : Results.Ok(item);
|
||||
})
|
||||
.WithName($"{routeNamePrefix}_get_by_id")
|
||||
.WithSummary("Get timeline event")
|
||||
.WithDescription(_t("timelineindexer.timeline.get_by_id_description"))
|
||||
.WithTags("timeline")
|
||||
.Produces<TimelineEventView>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
routes.MapGet("/timeline/{eventId}/evidence", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var evidence = await service.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return evidence is null ? Results.NotFound() : Results.Ok(evidence);
|
||||
})
|
||||
.WithName($"{routeNamePrefix}_get_evidence")
|
||||
.WithSummary("Get event evidence")
|
||||
.WithDescription(_t("timelineindexer.timeline.get_evidence_description"))
|
||||
.WithTags("timeline")
|
||||
.Produces<TimelineEvidenceView>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
routes.MapPost("/timeline/events", () =>
|
||||
Results.Accepted("/timeline/events", new TimelineIngestAcceptedResponse("indexed")))
|
||||
.WithName($"{routeNamePrefix}_ingest_event")
|
||||
.WithSummary("Ingest timeline event")
|
||||
.WithDescription(_t("timelineindexer.timeline.ingest_description"))
|
||||
.WithTags("timeline")
|
||||
.Produces<TimelineIngestAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
|
||||
}
|
||||
|
||||
public sealed record TimelineIngestAcceptedResponse(string Status);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:10231",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:10230;http://localhost:10231",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PackRouterTransportPlugins" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<RouterTransportPluginProject Condition="'$(RouterTransportPluginProject)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj</RouterTransportPluginProject>
|
||||
<MessagingTransportPluginProject Condition="'$(MessagingTransportPluginProject)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj</MessagingTransportPluginProject>
|
||||
<RouterTransportPluginSourceDir Condition="'$(RouterTransportPluginSourceDir)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Router.Transport.Messaging\bin\$(Configuration)\$(TargetFramework)</RouterTransportPluginSourceDir>
|
||||
<MessagingTransportPluginSourceDir Condition="'$(MessagingTransportPluginSourceDir)' == ''">$(MSBuildThisFileDirectory)..\..\Router\__Libraries\StellaOps.Messaging.Transport.Valkey\bin\$(Configuration)\$(TargetFramework)</MessagingTransportPluginSourceDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<MSBuild
|
||||
Projects="$(RouterTransportPluginProject);$(MessagingTransportPluginProject)"
|
||||
Targets="Restore;Build"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);CopyLocalLockFileAssemblies=true"
|
||||
BuildInParallel="false" />
|
||||
|
||||
<ItemGroup>
|
||||
<_RouterTransportPlugins Include="$(RouterTransportPluginSourceDir)\StellaOps*.dll" />
|
||||
<_RouterTransportPluginMetadata Include="$(RouterTransportPluginSourceDir)\*.deps.json" />
|
||||
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StellaOps*.dll" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll')" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll')" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll')" />
|
||||
<_MessagingTransportPluginMetadata Include="$(MessagingTransportPluginSourceDir)\*.deps.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<MakeDir Directories="$(PublishDir)plugins/router/transports" />
|
||||
<MakeDir Directories="$(PublishDir)plugins/messaging" />
|
||||
|
||||
<Copy
|
||||
SourceFiles="@(_RouterTransportPlugins)"
|
||||
DestinationFolder="$(PublishDir)plugins/router/transports"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_RouterTransportPluginMetadata)"
|
||||
DestinationFolder="$(PublishDir)plugins/router/transports"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_MessagingTransportPlugins)"
|
||||
DestinationFolder="$(PublishDir)plugins/messaging"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_MessagingTransportPluginMetadata)"
|
||||
DestinationFolder="$(PublishDir)plugins/messaging"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@StellaOps.TimelineIndexer.WebService_HostAddress = http://localhost:5194
|
||||
|
||||
GET {{StellaOps.TimelineIndexer.WebService_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,9 @@
|
||||
# StellaOps.TimelineIndexer.WebService Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| RVM-04 | DONE | Adopted `AddRouterMicroservice()` (plugin-driven transport registration), added `/api/v1/timeline*` alias routes, fixed plugin publish packaging with clean-container restore/build, and validated compose-default Valkey messaging registration + gateway OpenAPI endpoint/schema visibility. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.WebService/StellaOps.TimelineIndexer.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.WebService;
|
||||
|
||||
/// <summary>
|
||||
/// Logs authorization outcomes for timeline read/write operations.
|
||||
/// </summary>
|
||||
public sealed class TimelineAuthorizationAuditSink(ILogger<TimelineAuthorizationAuditSink> logger) : IAuthEventSink
|
||||
{
|
||||
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Auth {Outcome} for {EventType} tenant={Tenant} scopes={Scopes} subject={Subject} correlation={Correlation}",
|
||||
record.Outcome,
|
||||
record.EventType,
|
||||
record.Tenant.Value ?? "<none>",
|
||||
record.Scopes.Any() ? string.Join(" ", record.Scopes) : "<none>",
|
||||
record.Subject?.SubjectId.Value ?? "<unknown>",
|
||||
record.CorrelationId ?? "<none>");
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "timelineindexer", "version": "1.0" },
|
||||
|
||||
"timelineindexer.timeline.query_description": "Returns timeline events filtered by tenant and optional query parameters.",
|
||||
"timelineindexer.timeline.get_by_id_description": "Returns a single timeline event by event identifier for the current tenant.",
|
||||
"timelineindexer.timeline.get_evidence_description": "Returns evidence linkage for a timeline event, including bundle and attestation references.",
|
||||
"timelineindexer.timeline.ingest_description": "Queues an event ingestion request for asynchronous timeline indexing."
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
},
|
||||
"Authority": {
|
||||
"ResourceServer": {
|
||||
"Authority": "https://authority.localtest.me",
|
||||
"Audiences": [
|
||||
"api://timeline-indexer"
|
||||
],
|
||||
"RequiredTenants": [
|
||||
"tenant-default"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Postgres": {
|
||||
"Timeline": {
|
||||
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
|
||||
"SchemaName": "timeline",
|
||||
"CommandTimeoutSeconds": 30
|
||||
}
|
||||
},
|
||||
"TimelineIndexer": {
|
||||
"Router": {
|
||||
"Enabled": false,
|
||||
"Region": "local",
|
||||
"DefaultTimeoutSeconds": 30,
|
||||
"HeartbeatIntervalSeconds": 10,
|
||||
"TransportPlugins": {
|
||||
"Directory": "plugins/router/transports",
|
||||
"SearchPattern": "StellaOps.Router.Transport.*.dll"
|
||||
},
|
||||
"Gateways": [
|
||||
{
|
||||
"Host": "router.stella-ops.local",
|
||||
"Port": 9100,
|
||||
"TransportType": "Messaging"
|
||||
}
|
||||
],
|
||||
"Messaging": {
|
||||
"Transport": "valkey",
|
||||
"PluginDirectory": "plugins/messaging",
|
||||
"SearchPattern": "StellaOps.Messaging.Transport.*.dll",
|
||||
"RequestQueueTemplate": "router:requests:{service}",
|
||||
"ResponseQueueName": "router:responses",
|
||||
"ConsumerGroup": "timelineindexer",
|
||||
"RequestTimeout": "30s",
|
||||
"LeaseDuration": "5m",
|
||||
"BatchSize": 10,
|
||||
"HeartbeatInterval": "10s",
|
||||
"valkey": {
|
||||
"ConnectionString": "cache.stella-ops.local:6379"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Authority": {
|
||||
"ResourceServer": {
|
||||
"Authority": "https://authority.localtest.me",
|
||||
"Audiences": [
|
||||
"api://timeline-indexer"
|
||||
],
|
||||
"RequiredTenants": [
|
||||
"tenant-default"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Postgres": {
|
||||
"Timeline": {
|
||||
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
|
||||
"SchemaName": "timeline",
|
||||
"CommandTimeoutSeconds": 30
|
||||
}
|
||||
},
|
||||
"TimelineIndexer": {
|
||||
"Router": {
|
||||
"Enabled": false,
|
||||
"Region": "local",
|
||||
"DefaultTimeoutSeconds": 30,
|
||||
"HeartbeatIntervalSeconds": 10,
|
||||
"TransportPlugins": {
|
||||
"Directory": "plugins/router/transports",
|
||||
"SearchPattern": "StellaOps.Router.Transport.*.dll"
|
||||
},
|
||||
"Gateways": [
|
||||
{
|
||||
"Host": "router.stella-ops.local",
|
||||
"Port": 9100,
|
||||
"TransportType": "Messaging"
|
||||
}
|
||||
],
|
||||
"Messaging": {
|
||||
"Transport": "valkey",
|
||||
"PluginDirectory": "plugins/messaging",
|
||||
"SearchPattern": "StellaOps.Messaging.Transport.*.dll",
|
||||
"RequestQueueTemplate": "router:requests:{service}",
|
||||
"ResponseQueueName": "router:responses",
|
||||
"ConsumerGroup": "timelineindexer",
|
||||
"RequestTimeout": "30s",
|
||||
"LeaseDuration": "5m",
|
||||
"BatchSize": 10,
|
||||
"HeartbeatInterval": "10s",
|
||||
"valkey": {
|
||||
"ConnectionString": "cache.stella-ops.local:6379"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
29
src/Timeline/StellaOps.TimelineIndexer.Worker/Program.cs
Normal file
29
src/Timeline/StellaOps.TimelineIndexer.Worker/Program.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Options;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
using StellaOps.TimelineIndexer.Worker;
|
||||
using StellaOps.Worker.Health;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
|
||||
|
||||
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
|
||||
builder.Services.AddOptions<TimelineIngestionOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Ingestion"));
|
||||
|
||||
builder.Services.AddSingleton<TimelineEnvelopeParser>();
|
||||
builder.Services.AddSingleton<ITimelineEventSubscriber, NatsTimelineEventSubscriber>();
|
||||
builder.Services.AddSingleton<ITimelineEventSubscriber, RedisTimelineEventSubscriber>();
|
||||
builder.Services.AddSingleton<ITimelineEventSubscriber, NullTimelineEventSubscriber>();
|
||||
builder.Services.AddHostedService<TimelineIngestionWorker>();
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapWorkerHealthEndpoints();
|
||||
app.Run();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"StellaOps.TimelineIndexer.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<UserSecretsId>dotnet-StellaOps.TimelineIndexer.Worker-f6dbdeac-9eb5-4250-9384-ef93fc70f770</UserSecretsId>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- FrameworkReference Microsoft.AspNetCore.App is provided by Sdk.Web -->
|
||||
|
||||
|
||||
<!-- Microsoft.Extensions.Hosting is provided by Sdk.Worker -->
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj"/>
|
||||
|
||||
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Worker.Health/StellaOps.Worker.Health.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
8
src/Timeline/StellaOps.TimelineIndexer.Worker/TASKS.md
Normal file
8
src/Timeline/StellaOps.TimelineIndexer.Worker/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# StellaOps.TimelineIndexer.Worker Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Worker/StellaOps.TimelineIndexer.Worker.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// Background consumer that reads timeline events from configured subscribers and persists them via the ingestion service.
|
||||
/// </summary>
|
||||
public sealed class TimelineIngestionWorker(
|
||||
IEnumerable<ITimelineEventSubscriber> subscribers,
|
||||
ITimelineIngestionService ingestionService,
|
||||
ILogger<TimelineIngestionWorker> logger,
|
||||
TimeProvider? timeProvider = null) : BackgroundService
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.TimelineIndexer", "1.0.0");
|
||||
private static readonly Counter<long> IngestedCounter = Meter.CreateCounter<long>("timeline.ingested");
|
||||
private static readonly Counter<long> DuplicateCounter = Meter.CreateCounter<long>("timeline.duplicates");
|
||||
private static readonly Counter<long> FailedCounter = Meter.CreateCounter<long>("timeline.failed");
|
||||
private static readonly Histogram<double> LagHistogram = Meter.CreateHistogram<double>("timeline.ingest.lag.seconds");
|
||||
|
||||
private readonly IEnumerable<ITimelineEventSubscriber> _subscribers = subscribers;
|
||||
private readonly ITimelineIngestionService _ingestion = ingestionService;
|
||||
private readonly ILogger<TimelineIngestionWorker> _logger = logger;
|
||||
private readonly ConcurrentDictionary<(string tenant, string eventId), byte> _sessionSeen = new();
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var tasks = _subscribers.Select(subscriber => ConsumeAsync(subscriber, stoppingToken)).ToArray();
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task ConsumeAsync(ITimelineEventSubscriber subscriber, CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var envelope in subscriber.SubscribeAsync(cancellationToken))
|
||||
{
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
if (!_sessionSeen.TryAdd(key, 0))
|
||||
{
|
||||
DuplicateCounter.Add(1);
|
||||
_logger.LogDebug("Skipped duplicate timeline event {EventId} for tenant {Tenant}", envelope.EventId, envelope.TenantId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _ingestion.IngestAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Inserted)
|
||||
{
|
||||
IngestedCounter.Add(1);
|
||||
LagHistogram.Record((_timeProvider.GetUtcNow() - envelope.OccurredAt).TotalSeconds);
|
||||
_logger.LogInformation("Ingested timeline event {EventId} from {Source} (tenant {Tenant})", envelope.EventId, envelope.Source, envelope.TenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
DuplicateCounter.Add(1);
|
||||
_logger.LogDebug("Store reported duplicate for event {EventId} tenant {Tenant}", envelope.EventId, envelope.TenantId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Respect shutdown.
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FailedCounter.Add(1);
|
||||
_logger.LogError(ex, "Failed to ingest timeline event {EventId} for tenant {Tenant}", envelope.EventId, envelope.TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"Postgres": {
|
||||
"Timeline": {
|
||||
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
|
||||
"SchemaName": "timeline",
|
||||
"CommandTimeoutSeconds": 30
|
||||
}
|
||||
},
|
||||
"Ingestion": {
|
||||
"Nats": {
|
||||
"Enabled": false,
|
||||
"Url": "nats://localhost:4222",
|
||||
"Subject": "orch.event",
|
||||
"QueueGroup": "timeline-indexer",
|
||||
"Prefetch": 64
|
||||
},
|
||||
"Redis": {
|
||||
"Enabled": false,
|
||||
"ConnectionString": "localhost:6379",
|
||||
"Stream": "timeline.events",
|
||||
"ConsumerGroup": "timeline-indexer",
|
||||
"ConsumerName": "timeline-worker",
|
||||
"ValueField": "data",
|
||||
"MaxBatchSize": 128,
|
||||
"PollIntervalMilliseconds": 250
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"Postgres": {
|
||||
"Timeline": {
|
||||
"ConnectionString": "Host=localhost;Database=timeline;Username=timeline;Password=timeline;",
|
||||
"SchemaName": "timeline",
|
||||
"CommandTimeoutSeconds": 30
|
||||
}
|
||||
},
|
||||
"Ingestion": {
|
||||
"Nats": {
|
||||
"Enabled": false,
|
||||
"Url": "nats://localhost:4222",
|
||||
"Subject": "orch.event",
|
||||
"QueueGroup": "timeline-indexer",
|
||||
"Prefetch": 64
|
||||
},
|
||||
"Redis": {
|
||||
"Enabled": false,
|
||||
"ConnectionString": "localhost:6379",
|
||||
"Stream": "timeline.events",
|
||||
"ConsumerGroup": "timeline-indexer",
|
||||
"ConsumerName": "timeline-worker",
|
||||
"ValueField": "data",
|
||||
"MaxBatchSize": 128,
|
||||
"PollIntervalMilliseconds": 250
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence contract for timeline event ingestion.
|
||||
/// Implementations must enforce tenant isolation and idempotency on (tenant_id, event_id).
|
||||
/// </summary>
|
||||
public interface ITimelineEventStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Inserts the event atomically (headers, payloads, digests).
|
||||
/// Must be idempotent on (tenant_id, event_id) and return whether a new row was created.
|
||||
/// </summary>
|
||||
Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over transport-specific event subscriptions (NATS/Redis/etc.).
|
||||
/// Implementations yield tenant-scoped timeline event envelopes in publish order.
|
||||
/// </summary>
|
||||
public interface ITimelineEventSubscriber
|
||||
{
|
||||
IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// High-level ingestion service that validates, hashes, and persists timeline events.
|
||||
/// </summary>
|
||||
public interface ITimelineIngestionService
|
||||
{
|
||||
Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
public interface ITimelineQueryService
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
public interface ITimelineQueryStore
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
|
||||
public sealed record TimelineIngestResult(bool Inserted);
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical ingestion envelope for timeline events.
|
||||
/// Maps closely to orchestrator/notify envelopes while remaining transport-agnostic.
|
||||
/// </summary>
|
||||
public sealed class TimelineEventEnvelope
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string Severity { get; init; } = "info";
|
||||
public string? PayloadHash { get; set; }
|
||||
public string RawPayloadJson { get; init; } = "{}";
|
||||
public string? NormalizedPayloadJson { get; init; }
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
public string? BundleDigest { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Projected timeline event for query responses.
|
||||
/// </summary>
|
||||
public sealed class TimelineEventView
|
||||
{
|
||||
public required long EventSeq { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
public required DateTimeOffset ReceivedAt { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string Severity { get; init; } = "info";
|
||||
public string? PayloadHash { get; init; }
|
||||
public IDictionary<string, string>? Attributes { get; init; }
|
||||
public string? RawPayloadJson { get; init; }
|
||||
public string? NormalizedPayloadJson { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
|
||||
/// </summary>
|
||||
public sealed class TimelineEvidenceView
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Query filters for timeline listing.
|
||||
/// </summary>
|
||||
public sealed record TimelineQueryOptions
|
||||
{
|
||||
public string? EventType { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public long? AfterEventSeq { get; init; }
|
||||
public int Limit { get; init; } = 100;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates and persists timeline events with deterministic hashing.
|
||||
/// </summary>
|
||||
public sealed class TimelineIngestionService(ITimelineEventStore store) : ITimelineIngestionService
|
||||
{
|
||||
public async Task<TimelineIngestResult> IngestAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
Validate(envelope);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(envelope.PayloadHash))
|
||||
{
|
||||
envelope.PayloadHash = ComputePayloadHash(envelope.RawPayloadJson);
|
||||
}
|
||||
|
||||
var inserted = await store.InsertAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||
return new TimelineIngestResult(inserted);
|
||||
}
|
||||
|
||||
private static void Validate(TimelineEventEnvelope envelope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(envelope.EventId))
|
||||
throw new ArgumentException("event_id is required", nameof(envelope));
|
||||
if (string.IsNullOrWhiteSpace(envelope.TenantId))
|
||||
throw new ArgumentException("tenant_id is required", nameof(envelope));
|
||||
if (string.IsNullOrWhiteSpace(envelope.EventType))
|
||||
throw new ArgumentException("event_type is required", nameof(envelope));
|
||||
if (string.IsNullOrWhiteSpace(envelope.Source))
|
||||
throw new ArgumentException("source is required", nameof(envelope));
|
||||
if (string.IsNullOrWhiteSpace(envelope.RawPayloadJson))
|
||||
throw new ArgumentException("raw payload is required", nameof(envelope));
|
||||
}
|
||||
|
||||
internal static string ComputePayloadHash(string payloadJson)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(payloadJson ?? string.Empty);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQueryService
|
||||
{
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return store.QueryAsync(tenantId, Normalize(options), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
return store.GetAsync(tenantId, eventId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
if (evidence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = evidence.ManifestUri;
|
||||
if (manifest is null && evidence.BundleId is not null)
|
||||
{
|
||||
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = evidence.EventId,
|
||||
TenantId = evidence.TenantId,
|
||||
BundleId = evidence.BundleId,
|
||||
BundleDigest = evidence.BundleDigest,
|
||||
AttestationSubject = subject,
|
||||
AttestationDigest = evidence.AttestationDigest,
|
||||
ManifestUri = manifest,
|
||||
CreatedAt = evidence.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
|
||||
{
|
||||
var limit = options.Limit;
|
||||
if (limit <= 0) limit = 100;
|
||||
if (limit > 500) limit = 500;
|
||||
return options with { Limit = limit };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.TimelineIndexer.Core Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Core/StellaOps.TimelineIndexer.Core.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,114 @@
|
||||
-- 001_initial_schema.sql
|
||||
-- Establishes Timeline Indexer schema, RLS scaffolding, and evidence linkage tables.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS timeline;
|
||||
CREATE SCHEMA IF NOT EXISTS timeline_app;
|
||||
|
||||
-- Enforce tenant context for all RLS policies
|
||||
CREATE OR REPLACE FUNCTION timeline_app.require_current_tenant()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
tenant_text text;
|
||||
BEGIN
|
||||
tenant_text := current_setting('app.current_tenant', true);
|
||||
IF tenant_text IS NULL OR length(tenant_text) = 0 THEN
|
||||
RAISE EXCEPTION 'app.current_tenant is not set for the current session';
|
||||
END IF;
|
||||
RETURN tenant_text;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Severity enum keeps ordering deterministic and compact
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE timeline.event_severity AS ENUM ('info', 'notice', 'warn', 'error', 'critical');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Core event header table (dedupe + ordering)
|
||||
CREATE TABLE IF NOT EXISTS timeline.timeline_events
|
||||
(
|
||||
event_seq bigserial PRIMARY KEY,
|
||||
event_id text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
source text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
correlation_id text,
|
||||
trace_id text,
|
||||
actor text,
|
||||
severity timeline.event_severity NOT NULL DEFAULT 'info',
|
||||
payload_hash text CHECK (payload_hash IS NULL OR payload_hash ~ '^sha256:[0-9a-f]{64}$'),
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (tenant_id, event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_timeline_events_tenant_occurred
|
||||
ON timeline.timeline_events (tenant_id, occurred_at DESC, event_seq DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_timeline_events_type
|
||||
ON timeline.timeline_events (tenant_id, event_type, occurred_at DESC);
|
||||
|
||||
ALTER TABLE timeline.timeline_events ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS timeline_events_isolation ON timeline.timeline_events;
|
||||
CREATE POLICY timeline_events_isolation
|
||||
ON timeline.timeline_events
|
||||
USING (tenant_id = timeline_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
|
||||
|
||||
-- Raw and normalized payloads per event
|
||||
CREATE TABLE IF NOT EXISTS timeline.timeline_event_details
|
||||
(
|
||||
event_id text NOT NULL,
|
||||
tenant_id text NOT NULL,
|
||||
envelope_version text NOT NULL,
|
||||
raw_payload jsonb NOT NULL,
|
||||
normalized_payload jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT fk_event_details FOREIGN KEY (event_id, tenant_id)
|
||||
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (event_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE timeline.timeline_event_details ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS timeline_event_details_isolation ON timeline.timeline_event_details;
|
||||
CREATE POLICY timeline_event_details_isolation
|
||||
ON timeline.timeline_event_details
|
||||
USING (tenant_id = timeline_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
|
||||
|
||||
-- Evidence linkage (bundle/attestation manifests)
|
||||
CREATE TABLE IF NOT EXISTS timeline.timeline_event_digests
|
||||
(
|
||||
digest_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id text NOT NULL,
|
||||
event_id text NOT NULL,
|
||||
bundle_id uuid,
|
||||
bundle_digest text,
|
||||
attestation_subject text,
|
||||
attestation_digest text,
|
||||
manifest_uri text,
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT fk_event_digest_event FOREIGN KEY (event_id, tenant_id)
|
||||
REFERENCES timeline.timeline_events (event_id, tenant_id) ON DELETE CASCADE,
|
||||
CONSTRAINT ck_bundle_digest_sha CHECK (bundle_digest IS NULL OR bundle_digest ~ '^sha256:[0-9a-f]{64}$'),
|
||||
CONSTRAINT ck_attestation_digest_sha CHECK (attestation_digest IS NULL OR attestation_digest ~ '^sha256:[0-9a-f]{64}$')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_timeline_digests_event
|
||||
ON timeline.timeline_event_digests (tenant_id, event_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_timeline_digests_bundle
|
||||
ON timeline.timeline_event_digests (tenant_id, bundle_digest);
|
||||
|
||||
ALTER TABLE timeline.timeline_event_digests ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS timeline_event_digests_isolation ON timeline.timeline_event_digests;
|
||||
CREATE POLICY timeline_event_digests_isolation
|
||||
ON timeline.timeline_event_digests
|
||||
USING (tenant_id = timeline_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = timeline_app.require_current_tenant());
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed implementation of ITimelineEventStore.
|
||||
/// </summary>
|
||||
public sealed class TimelineEventStore(TimelineIndexerDataSource dataSource, ILogger<TimelineEventStore> logger)
|
||||
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineEventStore
|
||||
{
|
||||
public async Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(envelope.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
dbContext.timeline_events.Add(CreateTimelineEvent(envelope));
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
dbContext.timeline_event_details.Add(CreateTimelineEventDetail(envelope));
|
||||
|
||||
if (HasDigestPayload(envelope))
|
||||
{
|
||||
dbContext.timeline_event_digests.Add(CreateTimelineEventDigest(envelope));
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static timeline_event CreateTimelineEvent(TimelineEventEnvelope envelope)
|
||||
{
|
||||
return new timeline_event
|
||||
{
|
||||
event_id = envelope.EventId,
|
||||
tenant_id = envelope.TenantId,
|
||||
source = envelope.Source,
|
||||
event_type = envelope.EventType,
|
||||
occurred_at = envelope.OccurredAt.UtcDateTime,
|
||||
correlation_id = envelope.CorrelationId,
|
||||
trace_id = envelope.TraceId,
|
||||
actor = envelope.Actor,
|
||||
severity = TimelineEventSeverityExtensions.ParseOrDefault(envelope.Severity),
|
||||
payload_hash = envelope.PayloadHash,
|
||||
attributes = envelope.Attributes is null
|
||||
? "{}"
|
||||
: JsonSerializer.Serialize(envelope.Attributes)
|
||||
};
|
||||
}
|
||||
|
||||
private static timeline_event_detail CreateTimelineEventDetail(TimelineEventEnvelope envelope)
|
||||
{
|
||||
return new timeline_event_detail
|
||||
{
|
||||
event_id = envelope.EventId,
|
||||
tenant_id = envelope.TenantId,
|
||||
envelope_version = "orch.event.v1",
|
||||
raw_payload = envelope.RawPayloadJson,
|
||||
normalized_payload = envelope.NormalizedPayloadJson
|
||||
};
|
||||
}
|
||||
|
||||
private static timeline_event_digest CreateTimelineEventDigest(TimelineEventEnvelope envelope)
|
||||
{
|
||||
return new timeline_event_digest
|
||||
{
|
||||
tenant_id = envelope.TenantId,
|
||||
event_id = envelope.EventId,
|
||||
bundle_id = envelope.BundleId,
|
||||
bundle_digest = envelope.BundleDigest,
|
||||
attestation_subject = envelope.AttestationSubject,
|
||||
attestation_digest = envelope.AttestationDigest,
|
||||
manifest_uri = envelope.ManifestUri
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasDigestPayload(TimelineEventEnvelope envelope)
|
||||
{
|
||||
return envelope.BundleDigest is not null
|
||||
|| envelope.AttestationDigest is not null
|
||||
|| envelope.ManifestUri is not null
|
||||
|| envelope.BundleId is not null;
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
internal static class TimelineIndexerDbContextFactory
|
||||
{
|
||||
public static TimelineIndexerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
// Force usage of the static compiled model module for fast startup and deterministic metadata initialization.
|
||||
optionsBuilder.UseModel(TimelineIndexerDbContextModel.Instance);
|
||||
|
||||
var options = optionsBuilder.Options;
|
||||
|
||||
return new TimelineIndexerDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
/// <summary>
|
||||
/// Runs embedded SQL migrations for the Timeline Indexer schema.
|
||||
/// </summary>
|
||||
public sealed class TimelineIndexerMigrationRunner
|
||||
{
|
||||
private readonly PostgresOptions _options;
|
||||
private readonly ILogger<TimelineIndexerMigrationRunner> _logger;
|
||||
|
||||
private const string ResourcePrefix = "StellaOps.TimelineIndexer.Infrastructure.Db.Migrations";
|
||||
|
||||
public TimelineIndexerMigrationRunner(
|
||||
IOptions<PostgresOptions> options,
|
||||
ILogger<TimelineIndexerMigrationRunner> logger)
|
||||
{
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply all pending migrations from embedded resources.
|
||||
/// </summary>
|
||||
public Task<int> RunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var schema = string.IsNullOrWhiteSpace(_options.SchemaName)
|
||||
? TimelineIndexerDataSource.DefaultSchemaName
|
||||
: _options.SchemaName!;
|
||||
|
||||
var runner = new MigrationRunner(
|
||||
_options.ConnectionString,
|
||||
schema,
|
||||
moduleName: "TimelineIndexer",
|
||||
_logger);
|
||||
|
||||
return runner.RunFromAssemblyAsync(
|
||||
assembly: Assembly.GetExecutingAssembly(),
|
||||
resourcePrefix: ResourcePrefix,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILogger<TimelineQueryStore> logger)
|
||||
: RepositoryBase<TimelineIndexerDataSource>(dataSource, logger), ITimelineQueryStore
|
||||
{
|
||||
public async Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
|
||||
|
||||
var query = dbContext.timeline_events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.tenant_id == tenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.EventType))
|
||||
{
|
||||
query = query.Where(e => e.event_type == options.EventType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Source))
|
||||
{
|
||||
query = query.Where(e => e.source == options.Source);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.CorrelationId))
|
||||
{
|
||||
query = query.Where(e => e.correlation_id == options.CorrelationId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TraceId))
|
||||
{
|
||||
query = query.Where(e => e.trace_id == options.TraceId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Severity))
|
||||
{
|
||||
if (!TimelineEventSeverityExtensions.TryParse(options.Severity, out var severity))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
query = query.Where(e => e.severity == severity);
|
||||
}
|
||||
|
||||
if (options.Since is not null)
|
||||
{
|
||||
var sinceUtc = options.Since.Value.UtcDateTime;
|
||||
query = query.Where(e => e.occurred_at >= sinceUtc);
|
||||
}
|
||||
|
||||
if (options.AfterEventSeq is not null)
|
||||
{
|
||||
var afterSeq = options.AfterEventSeq.Value;
|
||||
query = query.Where(e => e.event_seq < afterSeq);
|
||||
}
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(e => e.occurred_at)
|
||||
.ThenByDescending(e => e.event_seq)
|
||||
.Take(Math.Clamp(options.Limit, 1, 500))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows
|
||||
.Select(MapEvent)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
|
||||
|
||||
var eventRow = await dbContext.timeline_events
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.tenant_id == tenantId && e.event_id == eventId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (eventRow is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var detailRow = await dbContext.timeline_event_details
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
d => d.tenant_id == tenantId && d.event_id == eventId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var digestRow = await dbContext.timeline_event_digests
|
||||
.AsNoTracking()
|
||||
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
|
||||
.OrderByDescending(d => d.created_at)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return MapEventDetail(eventRow, detailRow, digestRow);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = TimelineIndexerDbContextFactory.Create(connection, CommandTimeoutSeconds);
|
||||
|
||||
var digest = await dbContext.timeline_event_digests
|
||||
.AsNoTracking()
|
||||
.Where(d => d.tenant_id == tenantId && d.event_id == eventId)
|
||||
.OrderByDescending(d => d.created_at)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return digest is null ? null : MapEvidence(digest);
|
||||
}
|
||||
|
||||
private static TimelineEventView MapEvent(timeline_event row) => new()
|
||||
{
|
||||
EventSeq = row.event_seq,
|
||||
EventId = row.event_id,
|
||||
TenantId = row.tenant_id,
|
||||
EventType = row.event_type,
|
||||
Source = row.source,
|
||||
OccurredAt = ToUtcOffset(row.occurred_at),
|
||||
ReceivedAt = ToUtcOffset(row.received_at),
|
||||
CorrelationId = row.correlation_id,
|
||||
TraceId = row.trace_id,
|
||||
Actor = row.actor,
|
||||
Severity = row.severity.ToWireValue(),
|
||||
PayloadHash = row.payload_hash
|
||||
};
|
||||
|
||||
private static TimelineEventView MapEventDetail(
|
||||
timeline_event eventRow,
|
||||
timeline_event_detail? detailRow,
|
||||
timeline_event_digest? digestRow)
|
||||
{
|
||||
return new TimelineEventView
|
||||
{
|
||||
EventSeq = eventRow.event_seq,
|
||||
EventId = eventRow.event_id,
|
||||
TenantId = eventRow.tenant_id,
|
||||
EventType = eventRow.event_type,
|
||||
Source = eventRow.source,
|
||||
OccurredAt = ToUtcOffset(eventRow.occurred_at),
|
||||
ReceivedAt = ToUtcOffset(eventRow.received_at),
|
||||
CorrelationId = eventRow.correlation_id,
|
||||
TraceId = eventRow.trace_id,
|
||||
Actor = eventRow.actor,
|
||||
Severity = eventRow.severity.ToWireValue(),
|
||||
PayloadHash = eventRow.payload_hash,
|
||||
Attributes = DeserializeAttributes(eventRow.attributes),
|
||||
RawPayloadJson = detailRow?.raw_payload,
|
||||
NormalizedPayloadJson = detailRow?.normalized_payload,
|
||||
BundleId = digestRow?.bundle_id,
|
||||
BundleDigest = digestRow?.bundle_digest,
|
||||
AttestationSubject = digestRow?.attestation_subject,
|
||||
AttestationDigest = digestRow?.attestation_digest,
|
||||
ManifestUri = digestRow?.manifest_uri
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineEvidenceView MapEvidence(timeline_event_digest row)
|
||||
{
|
||||
var bundleDigest = row.bundle_digest;
|
||||
var attestationSubject = row.attestation_subject;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attestationSubject))
|
||||
{
|
||||
attestationSubject = bundleDigest;
|
||||
}
|
||||
|
||||
var bundleId = row.bundle_id;
|
||||
var manifestUri = row.manifest_uri;
|
||||
|
||||
if (manifestUri is null && bundleId is not null)
|
||||
{
|
||||
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = row.event_id,
|
||||
TenantId = row.tenant_id,
|
||||
BundleId = bundleId,
|
||||
BundleDigest = bundleDigest,
|
||||
AttestationSubject = attestationSubject,
|
||||
AttestationDigest = row.attestation_digest,
|
||||
ManifestUri = manifestUri,
|
||||
CreatedAt = ToUtcOffset(row.created_at)
|
||||
};
|
||||
}
|
||||
|
||||
private static IDictionary<string, string>? DeserializeAttributes(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(raw);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtcOffset(DateTime value)
|
||||
{
|
||||
if (value.Kind == DateTimeKind.Utc)
|
||||
{
|
||||
return new DateTimeOffset(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (value.Kind == DateTimeKind.Local)
|
||||
{
|
||||
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline Indexer PostgreSQL service registration helpers.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string DefaultSection = "Postgres:Timeline";
|
||||
|
||||
/// <summary>
|
||||
/// Registers Postgres options, data source, and migration runner for the Timeline Indexer.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTimelineIndexerPostgres(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = DefaultSection)
|
||||
{
|
||||
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
|
||||
services.AddSingleton<TimelineIndexerDataSource>();
|
||||
services.AddSingleton<TimelineIndexerMigrationRunner>();
|
||||
services.AddHostedService<TimelineIndexerMigrationHostedService>();
|
||||
services.AddScoped<ITimelineEventStore, TimelineEventStore>();
|
||||
services.AddScoped<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddScoped<ITimelineQueryStore, TimelineQueryStore>();
|
||||
services.AddScoped<ITimelineQueryService, TimelineQueryService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Db;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Executes TimelineIndexer schema migrations during application startup.
|
||||
/// </summary>
|
||||
internal sealed class TimelineIndexerMigrationHostedService(
|
||||
TimelineIndexerMigrationRunner migrationRunner,
|
||||
ILogger<TimelineIndexerMigrationHostedService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var applied = await migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogInformation(
|
||||
"TimelineIndexer startup migrations completed; applied {AppliedCount} migration(s).",
|
||||
applied);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(TimelineIndexerDbContext), typeof(TimelineIndexerDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(TimelineIndexerDbContext))]
|
||||
public partial class TimelineIndexerDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static TimelineIndexerDbContextModel()
|
||||
{
|
||||
var model = new TimelineIndexerDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (TimelineIndexerDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static TimelineIndexerDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
|
||||
{
|
||||
public partial class TimelineIndexerDbContextModel
|
||||
{
|
||||
private TimelineIndexerDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("f31ee807-87a4-4417-9dd0-2d0ae1361676"), entityTypeCount: 3)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var timeline_event = Timeline_eventEntityType.Create(this);
|
||||
var timeline_event_detail = Timeline_event_detailEntityType.Create(this);
|
||||
var timeline_event_digest = Timeline_event_digestEntityType.Create(this);
|
||||
|
||||
Timeline_event_detailEntityType.CreateForeignKey1(timeline_event_detail, timeline_event);
|
||||
Timeline_event_digestEntityType.CreateForeignKey1(timeline_event_digest, timeline_event);
|
||||
|
||||
Timeline_eventEntityType.CreateAnnotations(timeline_event);
|
||||
Timeline_event_detailEntityType.CreateAnnotations(timeline_event_detail);
|
||||
Timeline_event_digestEntityType.CreateAnnotations(timeline_event_digest);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class Timeline_eventEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event",
|
||||
typeof(timeline_event),
|
||||
baseEntityType,
|
||||
propertyCount: 13,
|
||||
navigationCount: 2,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 2);
|
||||
|
||||
var event_seq = runtimeEntityType.AddProperty(
|
||||
"event_seq",
|
||||
typeof(long),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("event_seq", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<event_seq>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: 0L);
|
||||
event_seq.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
|
||||
var actor = runtimeEntityType.AddProperty(
|
||||
"actor",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var attributes = runtimeEntityType.AddProperty(
|
||||
"attributes",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("attributes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<attributes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
attributes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
attributes.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
attributes.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var correlation_id = runtimeEntityType.AddProperty(
|
||||
"correlation_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("correlation_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<correlation_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
correlation_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var event_id = runtimeEntityType.AddProperty(
|
||||
"event_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var event_type = runtimeEntityType.AddProperty(
|
||||
"event_type",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("event_type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<event_type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
event_type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var occurred_at = runtimeEntityType.AddProperty(
|
||||
"occurred_at",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("occurred_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<occurred_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
occurred_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var payload_hash = runtimeEntityType.AddProperty(
|
||||
"payload_hash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("payload_hash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<payload_hash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
payload_hash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var received_at = runtimeEntityType.AddProperty(
|
||||
"received_at",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("received_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<received_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
received_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
received_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
|
||||
|
||||
var severity = runtimeEntityType.AddProperty(
|
||||
"severity",
|
||||
typeof(TimelineEventSeverity),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("severity", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<severity>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
severity.SetSentinelFromProviderValue(0);
|
||||
severity.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
severity.AddAnnotation("Relational:ColumnType", "timeline.event_severity");
|
||||
severity.AddAnnotation("Relational:DefaultValue", TimelineEventSeverity.Info);
|
||||
|
||||
var source = runtimeEntityType.AddProperty(
|
||||
"source",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("source", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<source>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
source.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var tenant_id = runtimeEntityType.AddProperty(
|
||||
"tenant_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var trace_id = runtimeEntityType.AddProperty(
|
||||
"trace_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("trace_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<trace_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
trace_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { event_seq });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "timeline_events_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { event_id, tenant_id });
|
||||
|
||||
var ix_timeline_events_tenant_occurred = runtimeEntityType.AddIndex(
|
||||
new[] { tenant_id, occurred_at, event_seq },
|
||||
name: "ix_timeline_events_tenant_occurred");
|
||||
|
||||
var ix_timeline_events_type = runtimeEntityType.AddIndex(
|
||||
new[] { tenant_id, event_type, occurred_at },
|
||||
name: "ix_timeline_events_type");
|
||||
|
||||
var timeline_events_tenant_id_event_id_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenant_id, event_id },
|
||||
name: "timeline_events_tenant_id_event_id_key",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_events");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class Timeline_event_detailEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_detail",
|
||||
typeof(timeline_event_detail),
|
||||
baseEntityType,
|
||||
propertyCount: 6,
|
||||
navigationCount: 1,
|
||||
foreignKeyCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var event_id = runtimeEntityType.AddProperty(
|
||||
"event_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var tenant_id = runtimeEntityType.AddProperty(
|
||||
"tenant_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var created_at = runtimeEntityType.AddProperty(
|
||||
"created_at",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
|
||||
|
||||
var envelope_version = runtimeEntityType.AddProperty(
|
||||
"envelope_version",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("envelope_version", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<envelope_version>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
envelope_version.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var normalized_payload = runtimeEntityType.AddProperty(
|
||||
"normalized_payload",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("normalized_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<normalized_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
normalized_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
normalized_payload.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var raw_payload = runtimeEntityType.AddProperty(
|
||||
"raw_payload",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("raw_payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<raw_payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
raw_payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
raw_payload.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { event_id, tenant_id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "timeline_event_details_pkey");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
|
||||
{
|
||||
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
|
||||
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
|
||||
principalEntityType,
|
||||
deleteBehavior: DeleteBehavior.Cascade,
|
||||
unique: true,
|
||||
required: true);
|
||||
|
||||
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
|
||||
runtimeForeignKey,
|
||||
onDependent: true,
|
||||
typeof(timeline_event),
|
||||
propertyInfo: typeof(timeline_event_detail).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_detail).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
var timeline_event_detail = principalEntityType.AddNavigation("timeline_event_detail",
|
||||
runtimeForeignKey,
|
||||
onDependent: false,
|
||||
typeof(timeline_event_detail),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_detail", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<timeline_event_detail>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_details");
|
||||
return runtimeForeignKey;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_details");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class Timeline_event_digestEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.TimelineIndexer.Infrastructure.EfCore.Models.timeline_event_digest",
|
||||
typeof(timeline_event_digest),
|
||||
baseEntityType,
|
||||
propertyCount: 9,
|
||||
navigationCount: 1,
|
||||
foreignKeyCount: 1,
|
||||
unnamedIndexCount: 1,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 1);
|
||||
|
||||
var digest_id = runtimeEntityType.AddProperty(
|
||||
"digest_id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("digest_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<digest_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
digest_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
digest_id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var attestation_digest = runtimeEntityType.AddProperty(
|
||||
"attestation_digest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
attestation_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var attestation_subject = runtimeEntityType.AddProperty(
|
||||
"attestation_subject",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("attestation_subject", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<attestation_subject>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
attestation_subject.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var bundle_digest = runtimeEntityType.AddProperty(
|
||||
"bundle_digest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_digest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_digest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
bundle_digest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var bundle_id = runtimeEntityType.AddProperty(
|
||||
"bundle_id",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("bundle_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<bundle_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
bundle_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var created_at = runtimeEntityType.AddProperty(
|
||||
"created_at",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("created_at", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<created_at>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
created_at.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
created_at.AddAnnotation("Relational:DefaultValueSql", "(now() AT TIME ZONE 'UTC'::text)");
|
||||
|
||||
var event_id = runtimeEntityType.AddProperty(
|
||||
"event_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("event_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<event_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
event_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var manifest_uri = runtimeEntityType.AddProperty(
|
||||
"manifest_uri",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("manifest_uri", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<manifest_uri>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
manifest_uri.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var tenant_id = runtimeEntityType.AddProperty(
|
||||
"tenant_id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("tenant_id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<tenant_id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenant_id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { digest_id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "timeline_event_digests_pkey");
|
||||
|
||||
var index = runtimeEntityType.AddIndex(
|
||||
new[] { event_id, tenant_id });
|
||||
|
||||
var ix_timeline_digests_bundle = runtimeEntityType.AddIndex(
|
||||
new[] { tenant_id, bundle_digest },
|
||||
name: "ix_timeline_digests_bundle");
|
||||
|
||||
var ix_timeline_digests_event = runtimeEntityType.AddIndex(
|
||||
new[] { tenant_id, event_id },
|
||||
name: "ix_timeline_digests_event");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType)
|
||||
{
|
||||
var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("event_id"), declaringEntityType.FindProperty("tenant_id") },
|
||||
principalEntityType.FindKey(new[] { principalEntityType.FindProperty("event_id"), principalEntityType.FindProperty("tenant_id") }),
|
||||
principalEntityType,
|
||||
deleteBehavior: DeleteBehavior.Cascade,
|
||||
required: true);
|
||||
|
||||
var timeline_event = declaringEntityType.AddNavigation("timeline_event",
|
||||
runtimeForeignKey,
|
||||
onDependent: true,
|
||||
typeof(timeline_event),
|
||||
propertyInfo: typeof(timeline_event_digest).GetProperty("timeline_event", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event_digest).GetField("<timeline_event>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
var timeline_event_digests = principalEntityType.AddNavigation("timeline_event_digests",
|
||||
runtimeForeignKey,
|
||||
onDependent: false,
|
||||
typeof(ICollection<timeline_event_digest>),
|
||||
propertyInfo: typeof(timeline_event).GetProperty("timeline_event_digests", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(timeline_event).GetField("<timeline_event_digests>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
runtimeForeignKey.AddAnnotation("Relational:Name", "fk_event_digest_event");
|
||||
return runtimeForeignKey;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "timeline");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "timeline_event_digests");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
public partial class TimelineIndexerDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<timeline_event>(entity =>
|
||||
{
|
||||
entity.Property(e => e.severity)
|
||||
.HasColumnType("timeline.event_severity")
|
||||
.HasDefaultValue(TimelineEventSeverity.Info);
|
||||
|
||||
entity.HasOne(e => e.timeline_event_detail)
|
||||
.WithOne(d => d.timeline_event)
|
||||
.HasPrincipalKey<timeline_event>(e => new { e.event_id, e.tenant_id })
|
||||
.HasForeignKey<timeline_event_detail>(d => new { d.event_id, d.tenant_id })
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_event_details");
|
||||
|
||||
entity.HasMany(e => e.timeline_event_digests)
|
||||
.WithOne(d => d.timeline_event)
|
||||
.HasPrincipalKey(e => new { e.event_id, e.tenant_id })
|
||||
.HasForeignKey(d => new { d.event_id, d.tenant_id })
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_event_digest_event");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
public partial class TimelineIndexerDbContext : DbContext
|
||||
{
|
||||
public TimelineIndexerDbContext(DbContextOptions<TimelineIndexerDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public virtual DbSet<timeline_event> timeline_events { get; set; }
|
||||
|
||||
public virtual DbSet<timeline_event_detail> timeline_event_details { get; set; }
|
||||
|
||||
public virtual DbSet<timeline_event_digest> timeline_event_digests { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder
|
||||
.HasPostgresEnum("timeline", "event_severity", new[] { "info", "notice", "warn", "error", "critical" })
|
||||
.HasPostgresExtension("pgcrypto");
|
||||
|
||||
modelBuilder.Entity<timeline_event>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.event_seq).HasName("timeline_events_pkey");
|
||||
|
||||
entity.ToTable("timeline_events", "timeline");
|
||||
|
||||
entity.HasIndex(e => new { e.tenant_id, e.occurred_at, e.event_seq }, "ix_timeline_events_tenant_occurred").IsDescending(false, true, true);
|
||||
|
||||
entity.HasIndex(e => new { e.tenant_id, e.event_type, e.occurred_at }, "ix_timeline_events_type").IsDescending(false, false, true);
|
||||
|
||||
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "timeline_events_tenant_id_event_id_key").IsUnique();
|
||||
|
||||
entity.Property(e => e.attributes)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb");
|
||||
entity.Property(e => e.received_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<timeline_event_detail>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.event_id, e.tenant_id }).HasName("timeline_event_details_pkey");
|
||||
|
||||
entity.ToTable("timeline_event_details", "timeline");
|
||||
|
||||
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
|
||||
entity.Property(e => e.normalized_payload).HasColumnType("jsonb");
|
||||
entity.Property(e => e.raw_payload).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<timeline_event_digest>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.digest_id).HasName("timeline_event_digests_pkey");
|
||||
|
||||
entity.ToTable("timeline_event_digests", "timeline");
|
||||
|
||||
entity.HasIndex(e => new { e.tenant_id, e.bundle_digest }, "ix_timeline_digests_bundle");
|
||||
|
||||
entity.HasIndex(e => new { e.tenant_id, e.event_id }, "ix_timeline_digests_event");
|
||||
|
||||
entity.Property(e => e.digest_id).HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.created_at).HasDefaultValueSql("(now() AT TIME ZONE 'UTC'::text)");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Context;
|
||||
|
||||
public sealed class TimelineIndexerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<TimelineIndexerDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString = "Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres";
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_TIMELINEINDEXER_EF_CONNECTION";
|
||||
|
||||
public TimelineIndexerDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
|
||||
var options = new DbContextOptionsBuilder<TimelineIndexerDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new TimelineIndexerDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CLR mapping for timeline.event_severity enum in PostgreSQL.
|
||||
/// </summary>
|
||||
public enum TimelineEventSeverity
|
||||
{
|
||||
[PgName("info")]
|
||||
Info,
|
||||
|
||||
[PgName("notice")]
|
||||
Notice,
|
||||
|
||||
[PgName("warn")]
|
||||
Warn,
|
||||
|
||||
[PgName("error")]
|
||||
Error,
|
||||
|
||||
[PgName("critical")]
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
internal static class TimelineEventSeverityExtensions
|
||||
{
|
||||
public static TimelineEventSeverity ParseOrDefault(string? value)
|
||||
=> TryParse(value, out var parsed) ? parsed : TimelineEventSeverity.Info;
|
||||
|
||||
public static bool TryParse(string? value, out TimelineEventSeverity parsed)
|
||||
{
|
||||
parsed = TimelineEventSeverity.Info;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Enum.TryParse(value, ignoreCase: true, out parsed);
|
||||
}
|
||||
|
||||
public static string ToWireValue(this TimelineEventSeverity value)
|
||||
=> value switch
|
||||
{
|
||||
TimelineEventSeverity.Info => "info",
|
||||
TimelineEventSeverity.Notice => "notice",
|
||||
TimelineEventSeverity.Warn => "warn",
|
||||
TimelineEventSeverity.Error => "error",
|
||||
TimelineEventSeverity.Critical => "critical",
|
||||
_ => "info"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event
|
||||
{
|
||||
public TimelineEventSeverity severity { get; set; } = TimelineEventSeverity.Info;
|
||||
|
||||
public virtual timeline_event_detail? timeline_event_detail { get; set; }
|
||||
|
||||
public virtual ICollection<timeline_event_digest> timeline_event_digests { get; set; } = new List<timeline_event_digest>();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event
|
||||
{
|
||||
public long event_seq { get; set; }
|
||||
|
||||
public string event_id { get; set; } = null!;
|
||||
|
||||
public string tenant_id { get; set; } = null!;
|
||||
|
||||
public string source { get; set; } = null!;
|
||||
|
||||
public string event_type { get; set; } = null!;
|
||||
|
||||
public DateTime occurred_at { get; set; }
|
||||
|
||||
public DateTime received_at { get; set; }
|
||||
|
||||
public string? correlation_id { get; set; }
|
||||
|
||||
public string? trace_id { get; set; }
|
||||
|
||||
public string? actor { get; set; }
|
||||
|
||||
public string? payload_hash { get; set; }
|
||||
|
||||
public string attributes { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event_detail
|
||||
{
|
||||
public virtual timeline_event timeline_event { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event_detail
|
||||
{
|
||||
public string event_id { get; set; } = null!;
|
||||
|
||||
public string tenant_id { get; set; } = null!;
|
||||
|
||||
public string envelope_version { get; set; } = null!;
|
||||
|
||||
public string raw_payload { get; set; } = null!;
|
||||
|
||||
public string? normalized_payload { get; set; }
|
||||
|
||||
public DateTime created_at { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event_digest
|
||||
{
|
||||
public virtual timeline_event timeline_event { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
public partial class timeline_event_digest
|
||||
{
|
||||
public Guid digest_id { get; set; }
|
||||
|
||||
public string tenant_id { get; set; } = null!;
|
||||
|
||||
public string event_id { get; set; } = null!;
|
||||
|
||||
public Guid? bundle_id { get; set; }
|
||||
|
||||
public string? bundle_digest { get; set; }
|
||||
|
||||
public string? attestation_subject { get; set; }
|
||||
|
||||
public string? attestation_digest { get; set; }
|
||||
|
||||
public string? manifest_uri { get; set; }
|
||||
|
||||
public DateTime created_at { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for timeline ingestion transports (NATS, Redis).
|
||||
/// </summary>
|
||||
public sealed class TimelineIngestionOptions
|
||||
{
|
||||
public NatsIngestionOptions Nats { get; init; } = new();
|
||||
public RedisIngestionOptions Redis { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class NatsIngestionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables NATS subscription when true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// NATS server URL (e.g., nats://localhost:4222).
|
||||
/// </summary>
|
||||
public string Url { get; init; } = "nats://localhost:4222";
|
||||
|
||||
/// <summary>
|
||||
/// Subject to subscribe to for orchestrator events.
|
||||
/// </summary>
|
||||
public string Subject { get; init; } = "orch.event";
|
||||
|
||||
/// <summary>
|
||||
/// Queue group for shared subscriptions to preserve ordering per subject.
|
||||
/// </summary>
|
||||
public string QueueGroup { get; init; } = "timeline-indexer";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum in-flight messages per subscriber.
|
||||
/// </summary>
|
||||
public int Prefetch { get; init; } = 64;
|
||||
}
|
||||
|
||||
public sealed class RedisIngestionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables Redis Stream subscription when true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Redis connection string (e.g., localhost:6379 or rediss://...).
|
||||
/// </summary>
|
||||
public string ConnectionString { get; init; } = "localhost:6379";
|
||||
|
||||
/// <summary>
|
||||
/// Stream name carrying timeline events.
|
||||
/// </summary>
|
||||
public string Stream { get; init; } = "timeline.events";
|
||||
|
||||
/// <summary>
|
||||
/// Consumer group used for ordered consumption.
|
||||
/// </summary>
|
||||
public string ConsumerGroup { get; init; } = "timeline-indexer";
|
||||
|
||||
/// <summary>
|
||||
/// Consumer name used when reading from the group.
|
||||
/// </summary>
|
||||
public string ConsumerName { get; init; } = Environment.MachineName ?? "timeline-indexer";
|
||||
|
||||
/// <summary>
|
||||
/// Field that contains the JSON payload within the stream entry.
|
||||
/// </summary>
|
||||
public string ValueField { get; init; } = "data";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum entries fetched per polling iteration.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; init; } = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Polling interval in milliseconds when no entries are available.
|
||||
/// </summary>
|
||||
public int PollIntervalMilliseconds { get; init; } = 250;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<ProjectReference Include="..\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj"/>
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Db/Migrations/*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NATS.Client.Core;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Options;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// NATS-based subscriber that yields orchestrator envelopes for ingestion.
|
||||
/// </summary>
|
||||
public sealed class NatsTimelineEventSubscriber : ITimelineEventSubscriber
|
||||
{
|
||||
private readonly IOptions<TimelineIngestionOptions> _options;
|
||||
private readonly TimelineEnvelopeParser _parser;
|
||||
private readonly ILogger<NatsTimelineEventSubscriber> _logger;
|
||||
|
||||
public NatsTimelineEventSubscriber(
|
||||
IOptions<TimelineIngestionOptions> options,
|
||||
TimelineEnvelopeParser parser,
|
||||
ILogger<NatsTimelineEventSubscriber> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cfg = _options.Value.Nats;
|
||||
if (!cfg.Enabled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await using var connection = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = cfg.Url,
|
||||
Name = "timeline-indexer"
|
||||
});
|
||||
|
||||
await foreach (var msg in connection.SubscribeAsync<byte[]>(
|
||||
cfg.Subject,
|
||||
queueGroup: cfg.QueueGroup,
|
||||
cancellationToken: cancellationToken))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var json = msg.Data is { Length: > 0 }
|
||||
? Encoding.UTF8.GetString(msg.Data)
|
||||
: string.Empty;
|
||||
|
||||
if (_parser.TryParse(json, out var envelope, out var reason))
|
||||
{
|
||||
yield return envelope;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Dropped NATS event on {Subject}: {Reason}", cfg.Subject, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Default no-op subscriber used until transport bindings are configured.
|
||||
/// Keeps the ingestion worker running without requiring live brokers.
|
||||
/// </summary>
|
||||
public sealed class NullTimelineEventSubscriber : ITimelineEventSubscriber
|
||||
{
|
||||
public IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AsyncEnumerable.Empty<TimelineEventEnvelope>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Options;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Redis Stream subscriber that reads orchestrator events and yields timeline envelopes.
|
||||
/// </summary>
|
||||
public sealed class RedisTimelineEventSubscriber : ITimelineEventSubscriber, IAsyncDisposable
|
||||
{
|
||||
private readonly IOptions<TimelineIngestionOptions> _options;
|
||||
private readonly TimelineEnvelopeParser _parser;
|
||||
private readonly ILogger<RedisTimelineEventSubscriber> _logger;
|
||||
private ConnectionMultiplexer? _connection;
|
||||
|
||||
public RedisTimelineEventSubscriber(
|
||||
IOptions<TimelineIngestionOptions> options,
|
||||
TimelineEnvelopeParser parser,
|
||||
ILogger<RedisTimelineEventSubscriber> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cfg = _options.Value.Redis;
|
||||
if (!cfg.Enabled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
_connection = await ConnectionMultiplexer.ConnectAsync(cfg.ConnectionString);
|
||||
var db = _connection.GetDatabase();
|
||||
|
||||
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
StreamEntry[] entries;
|
||||
try
|
||||
{
|
||||
entries = await db.StreamReadGroupAsync(
|
||||
cfg.Stream,
|
||||
cfg.ConsumerGroup,
|
||||
cfg.ConsumerName,
|
||||
">",
|
||||
count: cfg.MaxBatchSize,
|
||||
flags: CommandFlags.DemandMaster).ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("NOGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await EnsureGroupAsync(db, cfg, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
await Task.Delay(cfg.PollIntervalMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!TryGetValue(entry, cfg.ValueField, out var json))
|
||||
{
|
||||
_logger.LogWarning("Redis entry {EntryId} missing expected field {Field}", entry.Id, cfg.ValueField);
|
||||
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_parser.TryParse(json!, out var envelope, out var reason))
|
||||
{
|
||||
yield return envelope;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Redis entry {EntryId} dropped: {Reason}", entry.Id, reason);
|
||||
}
|
||||
|
||||
await db.StreamAcknowledgeAsync(cfg.Stream, cfg.ConsumerGroup, entry.Id).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureGroupAsync(IDatabase db, RedisIngestionOptions cfg, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.StreamCreateConsumerGroupAsync(cfg.Stream, cfg.ConsumerGroup, "$", true).ConfigureAwait(false);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Group already exists; nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetValue(in StreamEntry entry, string fieldName, out string? value)
|
||||
{
|
||||
foreach (var nv in entry.Values)
|
||||
{
|
||||
if (string.Equals(nv.Name, fieldName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = nv.Value.HasValue ? nv.Value.ToString() : null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Normalises incoming orchestrator/notification envelopes into <see cref="TimelineEventEnvelope"/> instances.
|
||||
/// </summary>
|
||||
public sealed class TimelineEnvelopeParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TimelineEnvelopeParser(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public bool TryParse(string rawJson, out TimelineEventEnvelope envelope, out string? failureReason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawJson))
|
||||
{
|
||||
envelope = default!;
|
||||
failureReason = "Payload was empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var eventId = FirstString(root, "eventId", "event_id", "id", "messageId");
|
||||
var tenantId = FirstString(root, "tenant", "tenantId", "tenant_id");
|
||||
var eventType = FirstString(root, "kind", "eventType", "event_type", "type");
|
||||
var source = FirstString(root, "source", "producer") ?? "unknown";
|
||||
var correlationId = FirstString(root, "correlationId", "correlation_id");
|
||||
var traceId = FirstString(root, "traceId", "trace_id");
|
||||
var actor = ExtractActor(root);
|
||||
var severity = (FirstString(root, "severity") ?? "info").ToLowerInvariant();
|
||||
var occurredAt = FirstDateTime(root, "occurredAt", "occurred_at", "timestamp", "ts") ?? _timeProvider.GetUtcNow();
|
||||
|
||||
var normalizedPayload = ExtractNormalizedPayload(root);
|
||||
var attributes = ExtractAttributes(root);
|
||||
|
||||
envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = eventId ?? throw new InvalidOperationException("event_id is required"),
|
||||
TenantId = tenantId ?? throw new InvalidOperationException("tenant_id is required"),
|
||||
EventType = eventType ?? throw new InvalidOperationException("event_type is required"),
|
||||
Source = source,
|
||||
OccurredAt = occurredAt,
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
Actor = actor,
|
||||
Severity = severity,
|
||||
RawPayloadJson = JsonSerializer.Serialize(root, SerializerOptions),
|
||||
NormalizedPayloadJson = normalizedPayload,
|
||||
Attributes = attributes,
|
||||
BundleDigest = FirstString(root, "bundleDigest"),
|
||||
BundleId = FirstGuid(root, "bundleId"),
|
||||
AttestationSubject = FirstString(root, "attestationSubject"),
|
||||
AttestationDigest = FirstString(root, "attestationDigest"),
|
||||
ManifestUri = FirstString(root, "manifestUri")
|
||||
};
|
||||
|
||||
failureReason = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
envelope = default!;
|
||||
failureReason = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractActor(JsonElement root)
|
||||
{
|
||||
if (TryGetProperty(root, "actor", out var actorElement))
|
||||
{
|
||||
if (actorElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return actorElement.GetString();
|
||||
}
|
||||
|
||||
if (actorElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (actorElement.TryGetProperty("subject", out var subject) && subject.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return subject.GetString();
|
||||
}
|
||||
|
||||
if (actorElement.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return user.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractNormalizedPayload(JsonElement root)
|
||||
{
|
||||
if (TryGetProperty(root, "payload", out var payload))
|
||||
{
|
||||
return JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "data", out var data) && data.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
return JsonSerializer.Serialize(data, SerializerOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IDictionary<string, string>? ExtractAttributes(JsonElement root)
|
||||
{
|
||||
if (!TryGetProperty(root, "attributes", out var attributes) || attributes.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in attributes.EnumerateObject())
|
||||
{
|
||||
var value = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.ToString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
dict[property.Name] = value!;
|
||||
}
|
||||
}
|
||||
|
||||
return dict.Count == 0 ? null : dict;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement root, string name, out JsonElement value)
|
||||
{
|
||||
if (root.TryGetProperty(name, out value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var property in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? FirstString(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Guid? FirstGuid(JsonElement root, params string[] names)
|
||||
{
|
||||
var text = FirstString(root, names);
|
||||
if (Guid.TryParse(text, out var guid))
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? FirstDateTime(JsonElement root, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (TryGetProperty(root, name, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text) && DateTimeOffset.TryParse(text, out var dto))
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# StellaOps.TimelineIndexer.Infrastructure Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth:
|
||||
- `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`
|
||||
- `docs/implplan/SPRINT_20260222_063_TimelineIndexer_smallest_webservice_dal_to_efcore.md`
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| TLI-EF-01 | DONE | EF Core scaffold baseline generated for timeline schema tables. |
|
||||
| TLI-EF-02 | DONE | `TimelineEventStore` and `TimelineQueryStore` converted from raw SQL/Npgsql commands to EF Core DAL. |
|
||||
| TLI-EF-03 | DONE | Sequential build/test validation complete and TimelineIndexer docs updated for EF persistence flow. |
|
||||
| TLI-EF-04 | DONE | Compiled model generated (`EfCore/CompiledModels`) and static module wired via `UseModel(TimelineIndexerDbContextModel.Instance)`. |
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.EfCore.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Timeline Indexer module.
|
||||
/// Sets the default schema and carries tenant context via app.current_tenant.
|
||||
/// </summary>
|
||||
public sealed class TimelineIndexerDataSource : DataSourceBase
|
||||
{
|
||||
public const string DefaultSchemaName = "timeline";
|
||||
|
||||
public TimelineIndexerDataSource(IOptions<PostgresOptions> options, ILogger<TimelineIndexerDataSource> logger)
|
||||
: base(EnsureSchema(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string ModuleName => "TimelineIndexer";
|
||||
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
builder.MapEnum<TimelineEventSeverity>("timeline.event_severity");
|
||||
}
|
||||
|
||||
private static PostgresOptions EnsureSchema(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -18,6 +19,7 @@ using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.Eventing;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.WebService.Audit;
|
||||
using StellaOps.Timeline.WebService.Endpoints;
|
||||
using Xunit;
|
||||
|
||||
@@ -249,6 +251,86 @@ public sealed class TimelineApiIntegrationTests : IClassFixture<TimelineWebAppli
|
||||
bundleContent.Should().Contain(correlationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task UnifiedAudit_PageLoadEndpoints_ReturnSuccess()
|
||||
{
|
||||
var statsResponse = await _client.GetAsync("/api/v1/audit/stats");
|
||||
var eventsResponse = await _client.GetAsync("/api/v1/audit/events?limit=10");
|
||||
var anomaliesResponse = await _client.GetAsync("/api/v1/audit/anomalies?limit=5");
|
||||
|
||||
statsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
eventsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
anomaliesResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task UnifiedAudit_EventByIdAndCorrelationsEndpoints_ReturnData()
|
||||
{
|
||||
var eventsResponse = await _client.GetFromJsonAsync<UnifiedAuditEventsPagedResponse>("/api/v1/audit/events?limit=20");
|
||||
eventsResponse.Should().NotBeNull();
|
||||
eventsResponse!.Items.Should().NotBeEmpty();
|
||||
|
||||
var selected = eventsResponse.Items.First(e => !string.IsNullOrWhiteSpace(e.CorrelationId));
|
||||
var eventByIdResponse = await _client.GetAsync($"/api/v1/audit/events/{selected.Id}");
|
||||
eventByIdResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var correlationResponse = await _client.GetAsync($"/api/v1/audit/correlations/{selected.CorrelationId}");
|
||||
correlationResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task UnifiedAudit_SearchTimeline_ReturnsEntriesForQuery()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/v1/audit/timeline/search?q=policy&limit=10");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var entries = await response.Content.ReadFromJsonAsync<IReadOnlyList<UnifiedAuditTimelineEntry>>();
|
||||
entries.Should().NotBeNull();
|
||||
entries!.Should().NotBeEmpty();
|
||||
entries.SelectMany(e => e.Events).Any(e => e.Module == "policy").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Operational")]
|
||||
public async Task UnifiedAudit_AnomalyAcknowledgeAndExportLifecycle_ReturnSuccess()
|
||||
{
|
||||
var anomalies = await _client.GetFromJsonAsync<IReadOnlyList<UnifiedAuditAnomalyAlert>>("/api/v1/audit/anomalies?limit=10");
|
||||
anomalies.Should().NotBeNull();
|
||||
anomalies!.Should().NotBeEmpty();
|
||||
|
||||
var target = anomalies[0];
|
||||
var acknowledgeResponse = await _client.PostAsJsonAsync($"/api/v1/audit/anomalies/{target.Id}/acknowledge", new { });
|
||||
acknowledgeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var acknowledged = await acknowledgeResponse.Content.ReadFromJsonAsync<UnifiedAuditAnomalyAlert>();
|
||||
acknowledged.Should().NotBeNull();
|
||||
acknowledged!.Acknowledged.Should().BeTrue();
|
||||
|
||||
var exportRequest = new UnifiedAuditExportRequest
|
||||
{
|
||||
Filters = new UnifiedAuditLogFilters
|
||||
{
|
||||
Modules = ["policy", "jobengine"]
|
||||
},
|
||||
Format = "json",
|
||||
IncludeDetails = true,
|
||||
IncludeDiffs = false
|
||||
};
|
||||
|
||||
var exportResponse = await _client.PostAsJsonAsync("/api/v1/audit/export", exportRequest);
|
||||
exportResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var export = await exportResponse.Content.ReadFromJsonAsync<UnifiedAuditExportResponse>();
|
||||
export.Should().NotBeNull();
|
||||
export!.Status.Should().Be("completed");
|
||||
|
||||
var exportStatusResponse = await _client.GetAsync($"/api/v1/audit/export/{export.ExportId}");
|
||||
exportStatusResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task SeedEventsAsync(string correlationId, int count)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
@@ -328,6 +410,11 @@ public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Stella
|
||||
services.RemoveAll<ITimelineEventStore>();
|
||||
services.RemoveAll<ITimelineEventEmitter>();
|
||||
services.RemoveAll<IHybridLogicalClock>();
|
||||
services.RemoveAll<IUnifiedAuditEventProvider>();
|
||||
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
|
||||
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
|
||||
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
|
||||
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
|
||||
|
||||
services.AddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
|
||||
services.AddSingleton<ITimelineEventEmitter, NoOpTimelineEventEmitter>();
|
||||
@@ -337,13 +424,18 @@ public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Stella
|
||||
"test-node",
|
||||
new InMemoryHlcStateStore(),
|
||||
NullLogger<StellaOps.HybridLogicalClock.HybridLogicalClock>.Instance));
|
||||
services.AddSingleton<IUnifiedAuditEventProvider, InMemoryUnifiedAuditEventProvider>();
|
||||
|
||||
// Override authentication with a test handler that always succeeds
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "TimelineTest";
|
||||
options.DefaultChallengeScheme = "TimelineTest";
|
||||
}).AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>("TimelineTest", _ => { });
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>("TimelineTest", _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, TimelineTestAuthHandler>(
|
||||
StellaOps.Auth.Abstractions.StellaOpsAuthenticationDefaults.AuthenticationScheme,
|
||||
_ => { });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -373,6 +465,186 @@ internal sealed class TimelineTestAuthHandler : AuthenticationHandler<Authentica
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryUnifiedAuditEventProvider : IUnifiedAuditEventProvider
|
||||
{
|
||||
private static readonly DateTimeOffset BaseTime = new(2026, 3, 3, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly IReadOnlyList<UnifiedAuditEvent> Events =
|
||||
[
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "policy-evt-0001",
|
||||
Timestamp = BaseTime.AddMinutes(1),
|
||||
Module = "policy",
|
||||
Action = "promote",
|
||||
Severity = "warning",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "alice",
|
||||
Name = "Alice Reviewer",
|
||||
Type = "user",
|
||||
Email = "alice@example.com"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "policy_pack",
|
||||
Id = "pack-001",
|
||||
Name = "Base Policy Pack"
|
||||
},
|
||||
Description = "Policy promotion queued for stage.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["packId"] = "pack-001"
|
||||
},
|
||||
CorrelationId = "corr-1001",
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["policy", "promotion"]
|
||||
},
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "policy-evt-0002",
|
||||
Timestamp = BaseTime.AddMinutes(2),
|
||||
Module = "policy",
|
||||
Action = "approve",
|
||||
Severity = "info",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "alice",
|
||||
Name = "Alice Reviewer",
|
||||
Type = "user",
|
||||
Email = "alice@example.com"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "policy_pack",
|
||||
Id = "pack-001",
|
||||
Name = "Base Policy Pack"
|
||||
},
|
||||
Description = "Promotion approved.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["approvalId"] = "approval-001"
|
||||
},
|
||||
CorrelationId = "corr-1001",
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["policy", "approval"]
|
||||
},
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "jobengine-evt-0001",
|
||||
Timestamp = BaseTime.AddMinutes(3),
|
||||
Module = "jobengine",
|
||||
Action = "fail",
|
||||
Severity = "error",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "jobengine-system",
|
||||
Name = "jobengine-system",
|
||||
Type = "system"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "workflow_run",
|
||||
Id = "run-001"
|
||||
},
|
||||
Description = "Workflow run failed due to unreachable target.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["runId"] = "run-001"
|
||||
},
|
||||
CorrelationId = "corr-1001",
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["jobengine", "failure"]
|
||||
},
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "authority-evt-0001",
|
||||
Timestamp = BaseTime.AddMinutes(4),
|
||||
Module = "authority",
|
||||
Action = "fail",
|
||||
Severity = "error",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "authority-system",
|
||||
Name = "authority-system",
|
||||
Type = "system"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "token",
|
||||
Id = "tok-001"
|
||||
},
|
||||
Description = "Authentication failure during token refresh.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["reason"] = "invalid_grant"
|
||||
},
|
||||
CorrelationId = "corr-auth-01",
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["authority", "auth"]
|
||||
},
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "integrations-evt-0001",
|
||||
Timestamp = BaseTime.AddMinutes(5),
|
||||
Module = "integrations",
|
||||
Action = "update",
|
||||
Severity = "info",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "svc-git",
|
||||
Name = "svc-git",
|
||||
Type = "service"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "integration",
|
||||
Id = "gitlab-main"
|
||||
},
|
||||
Description = "Integration configuration updated.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["integrationId"] = "gitlab-main"
|
||||
},
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["integrations"]
|
||||
},
|
||||
new UnifiedAuditEvent
|
||||
{
|
||||
Id = "sbom-evt-0001",
|
||||
Timestamp = BaseTime.AddMinutes(6),
|
||||
Module = "sbom",
|
||||
Action = "issue",
|
||||
Severity = "info",
|
||||
Actor = new UnifiedAuditActor
|
||||
{
|
||||
Id = "evidencelocker-system",
|
||||
Name = "evidencelocker-system",
|
||||
Type = "system"
|
||||
},
|
||||
Resource = new UnifiedAuditResource
|
||||
{
|
||||
Type = "evidence_pack",
|
||||
Id = "pack-9001"
|
||||
},
|
||||
Description = "Evidence pack sealed and indexed.",
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["packId"] = "pack-9001"
|
||||
},
|
||||
CorrelationId = "corr-2001",
|
||||
TenantId = "test-tenant",
|
||||
Tags = ["sbom", "evidence"]
|
||||
}
|
||||
];
|
||||
|
||||
public Task<IReadOnlyList<UnifiedAuditEvent>> GetEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(Events);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpTimelineEventEmitter : ITimelineEventEmitter
|
||||
{
|
||||
public Task<TimelineEvent> EmitAsync<TPayload>(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Offline integration test that wires the real parser + query store against the golden EB1 sealed bundle fixtures.
|
||||
/// </summary>
|
||||
public class EvidenceLinkageIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ParsesAndReturnsEvidenceFromSealedBundle()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var tenantId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var merkleRoot = "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596";
|
||||
var manifestUri = "bundles/11111111111111111111111111111111/manifest.dsse.json";
|
||||
|
||||
var manifestPath = ResolveFixturePath("src/__Tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json");
|
||||
var expectedPath = ResolveFixturePath("src/__Tests/EvidenceLocker/Bundles/Golden/sealed/expected.json");
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
|
||||
var expectedJson = await File.ReadAllTextAsync(expectedPath, CancellationToken.None);
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
var ok = parser.TryParse(EnvelopeForManifest(manifestJson), out var envelope, out var reason);
|
||||
Assert.True(ok, reason);
|
||||
|
||||
envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-eb1-demo",
|
||||
TenantId = tenantId,
|
||||
EventType = envelope.EventType,
|
||||
Source = envelope.Source,
|
||||
OccurredAt = envelope.OccurredAt,
|
||||
CorrelationId = envelope.CorrelationId,
|
||||
TraceId = envelope.TraceId,
|
||||
Actor = envelope.Actor,
|
||||
Severity = envelope.Severity,
|
||||
PayloadHash = envelope.PayloadHash,
|
||||
RawPayloadJson = envelope.RawPayloadJson,
|
||||
NormalizedPayloadJson = envelope.NormalizedPayloadJson,
|
||||
Attributes = envelope.Attributes,
|
||||
BundleId = bundleId,
|
||||
BundleDigest = merkleRoot,
|
||||
AttestationSubject = merkleRoot,
|
||||
AttestationDigest = merkleRoot,
|
||||
ManifestUri = manifestUri
|
||||
};
|
||||
|
||||
var store = new InMemoryQueryStore(envelope);
|
||||
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, envelope.EventId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(bundleId, evidence!.BundleId);
|
||||
Assert.Equal(merkleRoot, evidence.BundleDigest);
|
||||
Assert.Equal(manifestUri, evidence.ManifestUri);
|
||||
|
||||
using var doc = JsonDocument.Parse(expectedJson);
|
||||
var subject = doc.RootElement.GetProperty("subject").GetString();
|
||||
Assert.Equal(subject, evidence.AttestationSubject);
|
||||
}
|
||||
|
||||
private static string EnvelopeForManifest(string manifestJson)
|
||||
{
|
||||
return $@"{{
|
||||
""eventId"": ""evt-eb1-demo"",
|
||||
""tenant"": ""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"",
|
||||
""kind"": ""export.bundle.sealed"",
|
||||
""occurredAt"": ""2025-12-04T00:00:00Z"",
|
||||
""source"": ""evidence-locker"",
|
||||
""payload"": {{""manifest"": {{""raw"": {manifestJson} }}}},
|
||||
""bundleId"": ""11111111-1111-1111-1111-111111111111""
|
||||
}}";
|
||||
}
|
||||
|
||||
private static string ResolveFixturePath(string relative)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
// bin/Debug/net10.0/ -> StellaOps.TimelineIndexer.Tests -> TimelineIndexer -> src -> repo root
|
||||
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..", ".."));
|
||||
return Path.GetFullPath(Path.Combine(root, relative));
|
||||
}
|
||||
|
||||
private sealed class InMemoryQueryStore : ITimelineQueryStore
|
||||
{
|
||||
private readonly TimelineEventEnvelope _envelope;
|
||||
|
||||
public InMemoryQueryStore(TimelineEventEnvelope envelope)
|
||||
{
|
||||
_envelope = envelope;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<TimelineEventView?>(null);
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(tenantId, _envelope.TenantId, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(eventId, _envelope.EventId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<TimelineEvidenceView?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<TimelineEvidenceView?>(new TimelineEvidenceView
|
||||
{
|
||||
EventId = _envelope.EventId,
|
||||
TenantId = _envelope.TenantId,
|
||||
BundleId = _envelope.BundleId,
|
||||
BundleDigest = _envelope.BundleDigest,
|
||||
AttestationSubject = _envelope.AttestationSubject ?? _envelope.BundleDigest,
|
||||
AttestationDigest = _envelope.AttestationDigest,
|
||||
ManifestUri = _envelope.ManifestUri,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public sealed class InMemoryTimelineEventSubscriber : ITimelineEventSubscriber
|
||||
{
|
||||
private readonly Channel<TimelineEventEnvelope> _channel;
|
||||
|
||||
public InMemoryTimelineEventSubscriber(IEnumerable<TimelineEventEnvelope>? seed = null)
|
||||
{
|
||||
_channel = Channel.CreateUnbounded<TimelineEventEnvelope>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
if (seed is not null)
|
||||
{
|
||||
foreach (var envelope in seed)
|
||||
{
|
||||
_channel.Writer.TryWrite(envelope);
|
||||
}
|
||||
_channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(TimelineEventEnvelope envelope)
|
||||
{
|
||||
_channel.Writer.TryWrite(envelope);
|
||||
}
|
||||
|
||||
public void Complete() => _channel.Writer.TryComplete();
|
||||
|
||||
public IAsyncEnumerable<TimelineEventEnvelope> SubscribeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<UseXunitV3>true</UseXunitV3>
|
||||
|
||||
|
||||
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
|
||||
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
<Using Include="Xunit"/>
|
||||
|
||||
|
||||
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TimelineIndexer.Core/StellaOps.TimelineIndexer.Core.csproj"/>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="../../StellaOps.TimelineIndexer.Worker/StellaOps.TimelineIndexer.Worker.csproj"/>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System"/>
|
||||
<Using Include="System.Threading.Tasks"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.TimelineIndexer.Tests Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Tests/StellaOps.TimelineIndexer.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class TimelineEnvelopeParserTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parser_Maps_Required_Fields()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"eventId": "11111111-1111-1111-1111-111111111111",
|
||||
"tenant": "tenant-a",
|
||||
"kind": "scanner.event.report.ready",
|
||||
"occurredAt": "2025-12-01T12:00:00Z",
|
||||
"source": "scanner.webservice",
|
||||
"correlationId": "corr-1",
|
||||
"traceId": "trace-1",
|
||||
"attributes": {"key":"value"},
|
||||
"payload": {"reportId": "report-1"}
|
||||
}
|
||||
""";
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
|
||||
var parsed = parser.TryParse(json, out var envelope, out var reason);
|
||||
|
||||
Assert.True(parsed, reason);
|
||||
Assert.Equal("tenant-a", envelope.TenantId);
|
||||
Assert.Equal("scanner.event.report.ready", envelope.EventType);
|
||||
Assert.Equal("trace-1", envelope.TraceId);
|
||||
Assert.Equal("corr-1", envelope.CorrelationId);
|
||||
Assert.NotNull(envelope.Attributes);
|
||||
Assert.Equal("value", envelope.Attributes!["key"]);
|
||||
Assert.NotNull(envelope.RawPayloadJson);
|
||||
Assert.NotNull(envelope.NormalizedPayloadJson);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parser_Maps_Evidence_Metadata()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"eventId": "22222222-2222-2222-2222-222222222222",
|
||||
"tenantId": "tenant-b",
|
||||
"kind": "export.bundle.sealed",
|
||||
"occurredAt": "2025-12-02T01:02:03Z",
|
||||
"bundleId": "9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa",
|
||||
"bundleDigest": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationSubject": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
"manifestUri": "bundles/9f34f8c6/manifest.dsse.json"
|
||||
}
|
||||
""";
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
|
||||
var parsed = parser.TryParse(json, out var envelope, out var reason);
|
||||
|
||||
Assert.True(parsed, reason);
|
||||
Assert.Equal(Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"), envelope.BundleId);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.BundleDigest);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.AttestationSubject);
|
||||
Assert.Equal("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", envelope.AttestationDigest);
|
||||
Assert.Equal("bundles/9f34f8c6/manifest.dsse.json", envelope.ManifestUri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimelineIndexerCoreLogicTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
|
||||
// Tasks: TIMELINE-5100-001, TIMELINE-5100-002
|
||||
// Description: L0 Core logic tests and S1 idempotency tests for TimelineIndexer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// L0 Core Logic Tests and S1 Idempotency Tests
|
||||
/// Task TIMELINE-5100-001: L0 Event parsing (envelope → domain model → stored event)
|
||||
/// Task TIMELINE-5100-002: S1 Idempotency tests (same event_id, tenant → single insert)
|
||||
/// </summary>
|
||||
public sealed class TimelineIndexerCoreLogicTests
|
||||
{
|
||||
#region TIMELINE-5100-001: Event Parsing Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parse_EnvelopeToDomainModel_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var occurredAt = DateTimeOffset.Parse("2025-06-15T14:30:00Z");
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-parse-001",
|
||||
TenantId = "tenant-parse",
|
||||
EventType = "scan.completed",
|
||||
Source = "scanner",
|
||||
OccurredAt = occurredAt,
|
||||
RawPayloadJson = """{"findings":42,"severity":"high"}"""
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
store.LastEnvelope.Should().NotBeNull();
|
||||
store.LastEnvelope!.EventId.Should().Be("evt-parse-001");
|
||||
store.LastEnvelope.TenantId.Should().Be("tenant-parse");
|
||||
store.LastEnvelope.EventType.Should().Be("scan.completed");
|
||||
store.LastEnvelope.Source.Should().Be("scanner");
|
||||
store.LastEnvelope.OccurredAt.Should().Be(occurredAt);
|
||||
store.LastEnvelope.RawPayloadJson.Should().Be("""{"findings":42,"severity":"high"}""");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parse_ComputesPayloadHash_WhenMissing()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var payload = """{"status":"ok"}""";
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-hash-001",
|
||||
TenantId = "tenant-hash",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = payload
|
||||
// PayloadHash intentionally not set
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
store.LastEnvelope.Should().NotBeNull();
|
||||
store.LastEnvelope!.PayloadHash.Should().NotBeNullOrEmpty();
|
||||
store.LastEnvelope.PayloadHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parse_PayloadHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var store1 = new CountingStore();
|
||||
var store2 = new CountingStore();
|
||||
var service1 = new TimelineIngestionService(store1);
|
||||
var service2 = new TimelineIngestionService(store2);
|
||||
var payload = """{"deterministic":"test"}""";
|
||||
|
||||
var envelope1 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-det-001",
|
||||
TenantId = "tenant-det",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
RawPayloadJson = payload
|
||||
};
|
||||
|
||||
var envelope2 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-det-002",
|
||||
TenantId = "tenant-det",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
RawPayloadJson = payload
|
||||
};
|
||||
|
||||
// Act
|
||||
await service1.IngestAsync(envelope1, CancellationToken.None);
|
||||
await service2.IngestAsync(envelope2, CancellationToken.None);
|
||||
|
||||
// Assert - Same payload should produce same hash
|
||||
store1.LastEnvelope!.PayloadHash.Should().Be(store2.LastEnvelope!.PayloadHash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parse_PreservesEvidenceMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var bundleId = Guid.NewGuid();
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence-001",
|
||||
TenantId = "tenant-evidence",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = bundleId,
|
||||
BundleDigest = "sha256:bundledigest123",
|
||||
AttestationSubject = "sha256:attestsubject456",
|
||||
AttestationDigest = "sha256:attestdigest789",
|
||||
ManifestUri = $"bundles/{bundleId:N}/manifest.dsse.json"
|
||||
};
|
||||
|
||||
// Act
|
||||
await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
store.LastEnvelope.Should().NotBeNull();
|
||||
store.LastEnvelope!.BundleId.Should().Be(bundleId);
|
||||
store.LastEnvelope.BundleDigest.Should().Be("sha256:bundledigest123");
|
||||
store.LastEnvelope.AttestationSubject.Should().Be("sha256:attestsubject456");
|
||||
store.LastEnvelope.AttestationDigest.Should().Be("sha256:attestdigest789");
|
||||
store.LastEnvelope.ManifestUri.Should().Contain(bundleId.ToString("N"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parse_DifferentPayloads_ProduceDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var store1 = new CountingStore();
|
||||
var store2 = new CountingStore();
|
||||
var service1 = new TimelineIngestionService(store1);
|
||||
var service2 = new TimelineIngestionService(store2);
|
||||
|
||||
var envelope1 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-diff-001",
|
||||
TenantId = "tenant-diff",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = """{"value":1}"""
|
||||
};
|
||||
|
||||
var envelope2 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-diff-002",
|
||||
TenantId = "tenant-diff",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = """{"value":2}"""
|
||||
};
|
||||
|
||||
// Act
|
||||
await service1.IngestAsync(envelope1, CancellationToken.None);
|
||||
await service2.IngestAsync(envelope2, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
store1.LastEnvelope!.PayloadHash.Should().NotBe(store2.LastEnvelope!.PayloadHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TIMELINE-5100-002: Idempotency Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_SameEventId_SingleInsert()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-idem-001",
|
||||
TenantId = "tenant-idem",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
var result2 = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
var result3 = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result1.Inserted.Should().BeTrue("First insert should succeed");
|
||||
result2.Inserted.Should().BeFalse("Second insert should be idempotent");
|
||||
result3.Inserted.Should().BeFalse("Third insert should be idempotent");
|
||||
store.InsertCount.Should().Be(3, "Store receives all calls but returns false for duplicates");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_SameEventIdDifferentTenant_BothInsert()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
|
||||
var envelope1 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-tenant-001",
|
||||
TenantId = "tenant-A",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
var envelope2 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-tenant-001", // Same event ID
|
||||
TenantId = "tenant-B", // Different tenant
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await service.IngestAsync(envelope1, CancellationToken.None);
|
||||
var result2 = await service.IngestAsync(envelope2, CancellationToken.None);
|
||||
|
||||
// Assert - Same event ID but different tenants should both insert
|
||||
result1.Inserted.Should().BeTrue();
|
||||
result2.Inserted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_DifferentEventIdSameTenant_BothInsert()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
|
||||
var envelope1 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-multi-001",
|
||||
TenantId = "tenant-multi",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
var envelope2 = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-multi-002",
|
||||
TenantId = "tenant-multi",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await service.IngestAsync(envelope1, CancellationToken.None);
|
||||
var result2 = await service.IngestAsync(envelope2, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result1.Inserted.Should().BeTrue();
|
||||
result2.Inserted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_ConcurrentDuplicates_OnlyOneInserts()
|
||||
{
|
||||
// Arrange
|
||||
var store = new CountingStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-concurrent-001",
|
||||
TenantId = "tenant-concurrent",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
// Act - Submit many concurrent duplicates
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => service.IngestAsync(envelope, CancellationToken.None))
|
||||
.ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Only one should report Inserted = true
|
||||
results.Count(r => r.Inserted).Should().Be(1, "Only one concurrent insert should succeed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
private sealed class CountingStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
public TimelineEventEnvelope? LastEnvelope { get; private set; }
|
||||
public int InsertCount { get; private set; }
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastEnvelope = envelope;
|
||||
InsertCount++;
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
var inserted = _seen.Add(key);
|
||||
return Task.FromResult(inserted);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class TimelineIngestionServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Ingest_ComputesHash_WhenMissing()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-1",
|
||||
TenantId = "tenant-a",
|
||||
EventType = "job.completed",
|
||||
Source = "orchestrator",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = """{"ok":true}"""
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal("sha256:4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", store.LastEnvelope?.PayloadHash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Ingest_IsIdempotent_OnSameEventId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-dup",
|
||||
TenantId = "tenant-a",
|
||||
EventType = "job.completed",
|
||||
Source = "orchestrator",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
var first = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
var second = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
Assert.True(first.Inserted);
|
||||
Assert.False(second.Inserted);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Ingest_PersistsEvidenceMetadata_WhenPresent()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-12-02T01:02:03Z"),
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal(envelope.BundleId, store.LastEnvelope?.BundleId);
|
||||
Assert.Equal(envelope.BundleDigest, store.LastEnvelope?.BundleDigest);
|
||||
Assert.Equal(envelope.AttestationSubject, store.LastEnvelope?.AttestationSubject);
|
||||
Assert.Equal(envelope.AttestationDigest, store.LastEnvelope?.AttestationDigest);
|
||||
Assert.Equal(envelope.ManifestUri, store.LastEnvelope?.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
public TimelineEventEnvelope? LastEnvelope { get; private set; }
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastEnvelope = envelope;
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
var inserted = _seen.Add(key);
|
||||
return Task.FromResult(inserted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
using StellaOps.TimelineIndexer.Worker;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public sealed class TimelineIngestionWorkerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_Ingests_And_Dedupes()
|
||||
{
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new RecordingStore();
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
serviceCollection.AddSingleton<ITimelineEventStore>(store);
|
||||
serviceCollection.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
serviceCollection.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
serviceCollection.AddLogging();
|
||||
|
||||
using var host = serviceCollection.BuildServiceProvider();
|
||||
var hosted = host.GetRequiredService<IHostedService>();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var evt = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-1",
|
||||
TenantId = "tenant-a",
|
||||
EventType = "test",
|
||||
Source = "unit",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
subscriber.Enqueue(evt);
|
||||
subscriber.Enqueue(evt); // duplicate
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(200, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
Assert.Equal(1, store.InsertCalls); // duplicate dropped
|
||||
Assert.Equal("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", store.LastHash); // hash of "{}"
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_Passes_Evidence_Metadata()
|
||||
{
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new RecordingStore();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var evt = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence-worker",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
subscriber.Enqueue(evt);
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(200, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
Assert.Equal(evt.BundleId, store.LastBundleId);
|
||||
Assert.Equal(evt.BundleDigest, store.LastBundleDigest);
|
||||
Assert.Equal(evt.AttestationSubject, store.LastAttestationSubject);
|
||||
Assert.Equal(evt.AttestationDigest, store.LastAttestationDigest);
|
||||
Assert.Equal(evt.ManifestUri, store.LastManifestUri);
|
||||
}
|
||||
|
||||
private sealed class RecordingStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
public int InsertCalls { get; private set; }
|
||||
public string? LastHash { get; private set; }
|
||||
public Guid? LastBundleId { get; private set; }
|
||||
public string? LastBundleDigest { get; private set; }
|
||||
public string? LastAttestationSubject { get; private set; }
|
||||
public string? LastAttestationDigest { get; private set; }
|
||||
public string? LastManifestUri { get; private set; }
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertCalls++;
|
||||
LastHash = envelope.PayloadHash;
|
||||
LastBundleId = envelope.BundleId;
|
||||
LastBundleDigest = envelope.BundleDigest;
|
||||
LastAttestationSubject = envelope.AttestationSubject;
|
||||
LastAttestationDigest = envelope.AttestationDigest;
|
||||
LastManifestUri = envelope.ManifestUri;
|
||||
return Task.FromResult(_seen.Add((envelope.TenantId, envelope.EventId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimelineIntegrationTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
|
||||
// Task: TIMELINE-5100-006
|
||||
// Description: Integration tests (event → index → query returns event)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration Tests
|
||||
/// Task TIMELINE-5100-006: Event → index → query returns event with correct data
|
||||
/// </summary>
|
||||
public sealed class TimelineIntegrationTests
|
||||
{
|
||||
#region Full Pipeline Integration
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_IngestThenQuery_ReturnsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-integration-001",
|
||||
TenantId = "tenant-integration",
|
||||
EventType = "scan.completed",
|
||||
Source = "scanner",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-06-15T10:00:00Z"),
|
||||
RawPayloadJson = """{"findings":5,"severity":"medium"}"""
|
||||
};
|
||||
|
||||
// Act - Ingest
|
||||
var ingestResult = await ingestionService.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
// Act - Query
|
||||
var queryResult = await queryService.GetAsync("tenant-integration", "evt-integration-001", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
ingestResult.Inserted.Should().BeTrue();
|
||||
queryResult.Should().NotBeNull();
|
||||
queryResult!.EventId.Should().Be("evt-integration-001");
|
||||
queryResult.TenantId.Should().Be("tenant-integration");
|
||||
queryResult.EventType.Should().Be("scan.completed");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_QueryByTenant_ReturnsOnlyTenantEvents()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
// Ingest events for different tenants
|
||||
await ingestionService.IngestAsync(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-tenant1-001",
|
||||
TenantId = "tenant-1",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
}, CancellationToken.None);
|
||||
|
||||
await ingestionService.IngestAsync(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-tenant2-001",
|
||||
TenantId = "tenant-2",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act - Query for tenant-1 events
|
||||
var options = new TimelineQueryOptions { Limit = 100 };
|
||||
var tenant1Events = await queryService.QueryAsync("tenant-1", options, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
tenant1Events.Should().ContainSingle();
|
||||
tenant1Events.Should().OnlyContain(e => e.TenantId == "tenant-1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_QueryWithLimit_RespectsLimit()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
// Ingest multiple events
|
||||
for (int i = 1; i <= 10; i++)
|
||||
{
|
||||
await ingestionService.IngestAsync(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = $"evt-limit-{i:D3}",
|
||||
TenantId = "tenant-limit",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddMinutes(i),
|
||||
RawPayloadJson = $"{{\"seq\":{i}}}"
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act - Query with limit
|
||||
var options = new TimelineQueryOptions { Limit = 5 };
|
||||
var events = await queryService.QueryAsync("tenant-limit", options, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
events.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence Metadata Integration
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_EvidenceMetadata_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
var bundleId = Guid.NewGuid();
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence-round",
|
||||
TenantId = "tenant-evidence",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = bundleId,
|
||||
BundleDigest = "sha256:bundlehash123",
|
||||
AttestationSubject = "sha256:attestsubject",
|
||||
AttestationDigest = "sha256:attestdigest",
|
||||
ManifestUri = $"bundles/{bundleId:N}/manifest.dsse.json"
|
||||
};
|
||||
|
||||
// Act
|
||||
await ingestionService.IngestAsync(envelope, CancellationToken.None);
|
||||
var evidence = await queryService.GetEvidenceAsync("tenant-evidence", "evt-evidence-round", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
evidence.Should().NotBeNull();
|
||||
evidence!.BundleId.Should().Be(bundleId);
|
||||
evidence.BundleDigest.Should().Be("sha256:bundlehash123");
|
||||
evidence.AttestationSubject.Should().Be("sha256:attestsubject");
|
||||
evidence.AttestationDigest.Should().Be("sha256:attestdigest");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_PayloadHash_IsPersisted()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-hash-persist",
|
||||
TenantId = "tenant-hash",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = """{"data":"test-data"}"""
|
||||
};
|
||||
|
||||
// Act
|
||||
await ingestionService.IngestAsync(envelope, CancellationToken.None);
|
||||
var stored = store.GetStoredEnvelope("tenant-hash", "evt-hash-persist");
|
||||
|
||||
// Assert
|
||||
stored.Should().NotBeNull();
|
||||
stored!.PayloadHash.Should().NotBeNullOrEmpty();
|
||||
stored.PayloadHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_SameInput_ProducesSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var store1 = new InMemoryTimelineStore();
|
||||
var store2 = new InMemoryTimelineStore();
|
||||
var service1 = new TimelineIngestionService(store1);
|
||||
var service2 = new TimelineIngestionService(store2);
|
||||
|
||||
var timestamp = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-determinism",
|
||||
TenantId = "tenant-determinism",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = timestamp,
|
||||
RawPayloadJson = """{"key":"value"}"""
|
||||
};
|
||||
|
||||
// Act
|
||||
await service1.IngestAsync(envelope, CancellationToken.None);
|
||||
await service2.IngestAsync(envelope, CancellationToken.None);
|
||||
|
||||
var stored1 = store1.GetStoredEnvelope("tenant-determinism", "evt-determinism");
|
||||
var stored2 = store2.GetStoredEnvelope("tenant-determinism", "evt-determinism");
|
||||
|
||||
// Assert - Both should have identical hashes
|
||||
stored1!.PayloadHash.Should().Be(stored2!.PayloadHash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FullPipeline_QueryOrdering_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
// Ingest in random order
|
||||
var events = new[]
|
||||
{
|
||||
("evt-03", DateTimeOffset.Parse("2025-06-15T10:30:00Z")),
|
||||
("evt-01", DateTimeOffset.Parse("2025-06-15T10:00:00Z")),
|
||||
("evt-05", DateTimeOffset.Parse("2025-06-15T11:00:00Z")),
|
||||
("evt-02", DateTimeOffset.Parse("2025-06-15T10:15:00Z")),
|
||||
("evt-04", DateTimeOffset.Parse("2025-06-15T10:45:00Z"))
|
||||
};
|
||||
|
||||
foreach (var (id, occurredAt) in events)
|
||||
{
|
||||
await ingestionService.IngestAsync(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = id,
|
||||
TenantId = "tenant-order",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = occurredAt,
|
||||
RawPayloadJson = "{}"
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act - Query multiple times
|
||||
var options = new TimelineQueryOptions { Limit = 100 };
|
||||
var result1 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
|
||||
var result2 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
|
||||
var result3 = await queryService.QueryAsync("tenant-order", options, CancellationToken.None);
|
||||
|
||||
// Assert - All queries should return same order
|
||||
var ids1 = result1.Select(e => e.EventId).ToList();
|
||||
var ids2 = result2.Select(e => e.EventId).ToList();
|
||||
var ids3 = result3.Select(e => e.EventId).ToList();
|
||||
|
||||
ids1.Should().BeEquivalentTo(ids2, options => options.WithStrictOrdering());
|
||||
ids2.Should().BeEquivalentTo(ids3, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Query_NonExistentEvent_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
// Act
|
||||
var result = await queryService.GetAsync("tenant-missing", "evt-nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Query_NonExistentTenant_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryTimelineStore();
|
||||
var ingestionService = new TimelineIngestionService(store);
|
||||
var queryService = new TimelineQueryService(store);
|
||||
|
||||
// Ingest an event for a different tenant
|
||||
await ingestionService.IngestAsync(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-exists",
|
||||
TenantId = "tenant-exists",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var options = new TimelineQueryOptions { Limit = 100 };
|
||||
var results = await queryService.QueryAsync("tenant-nonexistent", options, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
private sealed class InMemoryTimelineStore : ITimelineEventStore, ITimelineQueryStore
|
||||
{
|
||||
private readonly Dictionary<(string tenant, string id), TimelineEventEnvelope> _events = new();
|
||||
private readonly Dictionary<(string tenant, string id), TimelineEvidenceView> _evidence = new();
|
||||
|
||||
public TimelineEventEnvelope? GetStoredEnvelope(string tenant, string eventId)
|
||||
{
|
||||
return _events.TryGetValue((tenant, eventId), out var envelope) ? envelope : null;
|
||||
}
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
if (_events.ContainsKey(key))
|
||||
return Task.FromResult(false);
|
||||
|
||||
_events[key] = envelope;
|
||||
|
||||
// Store evidence if present
|
||||
if (envelope.BundleId.HasValue)
|
||||
{
|
||||
_evidence[key] = new TimelineEvidenceView
|
||||
{
|
||||
EventId = envelope.EventId,
|
||||
TenantId = envelope.TenantId,
|
||||
BundleId = envelope.BundleId.Value,
|
||||
BundleDigest = envelope.BundleDigest!,
|
||||
AttestationSubject = envelope.AttestationSubject!,
|
||||
AttestationDigest = envelope.AttestationDigest!,
|
||||
ManifestUri = envelope.ManifestUri,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var events = _events
|
||||
.Where(kvp => kvp.Key.tenant == tenantId)
|
||||
.Select((kvp, idx) => new TimelineEventView
|
||||
{
|
||||
EventSeq = idx + 1,
|
||||
EventId = kvp.Value.EventId,
|
||||
TenantId = kvp.Value.TenantId,
|
||||
EventType = kvp.Value.EventType,
|
||||
Source = kvp.Value.Source,
|
||||
OccurredAt = kvp.Value.OccurredAt,
|
||||
ReceivedAt = kvp.Value.OccurredAt
|
||||
})
|
||||
.OrderBy(e => e.OccurredAt)
|
||||
.Take(options.Limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimelineEventView>>(events);
|
||||
}
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_events.TryGetValue((tenantId, eventId), out var envelope))
|
||||
{
|
||||
return Task.FromResult<TimelineEventView?>(new TimelineEventView
|
||||
{
|
||||
EventSeq = 1,
|
||||
EventId = envelope.EventId,
|
||||
TenantId = envelope.TenantId,
|
||||
EventType = envelope.EventType,
|
||||
Source = envelope.Source,
|
||||
OccurredAt = envelope.OccurredAt,
|
||||
ReceivedAt = envelope.OccurredAt
|
||||
});
|
||||
}
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_evidence.TryGetValue((tenantId, eventId), out var evidence) ? evidence : null);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class TimelineQueryServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryAsync_ClampsLimit()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
var options = new TimelineQueryOptions { Limit = 2000 };
|
||||
|
||||
await service.QueryAsync("tenant-a", options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(500, store.LastOptions?.Limit);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetAsync("tenant-1", "evt-1", CancellationToken.None);
|
||||
|
||||
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetEvidenceAsync("tenant-x", "evt-evidence", CancellationToken.None);
|
||||
|
||||
Assert.Equal(("tenant-x", "evt-evidence"), store.LastEvidenceGet);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_FillsManifestUriFromBundleId_WhenMissing()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var store = new FakeStore
|
||||
{
|
||||
Evidence = new TimelineEvidenceView
|
||||
{
|
||||
EventId = "evt",
|
||||
TenantId = "tenant",
|
||||
BundleId = bundleId,
|
||||
BundleDigest = "sha256:deadbeef",
|
||||
AttestationSubject = "sha256:deadbeef",
|
||||
AttestationDigest = "sha256:feedface",
|
||||
ManifestUri = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
var evidence = await service.GetEvidenceAsync("tenant", "evt", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal($"bundles/{bundleId:N}/manifest.dsse.json", evidence!.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineQueryStore
|
||||
{
|
||||
public TimelineQueryOptions? LastOptions { get; private set; }
|
||||
public (string tenant, string id)? LastGet { get; private set; }
|
||||
public (string tenant, string id)? LastEvidenceGet { get; private set; }
|
||||
public TimelineEvidenceView? Evidence { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastOptions = options;
|
||||
return Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
}
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastGet = (tenantId, eventId);
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastEvidenceGet = (tenantId, eventId);
|
||||
return Task.FromResult(Evidence);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public sealed class TimelineSchemaTests
|
||||
{
|
||||
private static string FindMigrationPath()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 12 && dir is not null; i++)
|
||||
{
|
||||
var candidate = Path.Combine(dir, "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var infraCandidate = Path.Combine(dir, "StellaOps.TimelineIndexer.Infrastructure", "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(infraCandidate))
|
||||
{
|
||||
return infraCandidate;
|
||||
}
|
||||
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Expected migration file was not found after traversing upward.", "(Db/Migrations/001_initial_schema.sql)");
|
||||
}
|
||||
|
||||
private static string ReadMigrationSql()
|
||||
{
|
||||
var path = FindMigrationPath();
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MigrationFile_Exists()
|
||||
{
|
||||
var path = FindMigrationPath();
|
||||
Assert.True(File.Exists(path), $"Migration script missing at {path}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration_EnablesRlsPolicies()
|
||||
{
|
||||
var sql = ReadMigrationSql();
|
||||
|
||||
Assert.Contains("timeline_app.require_current_tenant", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("timeline_events_isolation", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("timeline_event_details_isolation", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("timeline_event_digests_isolation", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ENABLE ROW LEVEL SECURITY", sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration_DefinesUniqueEventConstraint()
|
||||
{
|
||||
var sql = ReadMigrationSql();
|
||||
|
||||
Assert.Contains("UNIQUE (tenant_id, event_id)", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("event_seq bigserial", sql, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimelineWorkerEndToEndTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0002_graph_timeline_tests
|
||||
// Tasks: TIMELINE-5100-003, TIMELINE-5100-004, TIMELINE-5100-005
|
||||
// Description: WK1 Worker end-to-end, retry tests, and OTel correlation tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
using StellaOps.TimelineIndexer.Worker;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WK1 Worker Layer Tests
|
||||
/// Task TIMELINE-5100-003: Worker end-to-end (subscribe → process → ack)
|
||||
/// Task TIMELINE-5100-004: Retry tests (transient fail → retry → success)
|
||||
/// Task TIMELINE-5100-005: OTel correlation (trace_id from event propagates to span)
|
||||
/// </summary>
|
||||
public sealed class TimelineWorkerEndToEndTests
|
||||
{
|
||||
#region TIMELINE-5100-003: Worker End-to-End Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_SubscribesAndProcessesEvents()
|
||||
{
|
||||
// Arrange
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TrackingStore();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var evt = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-e2e-001",
|
||||
TenantId = "tenant-e2e",
|
||||
EventType = "scan.started",
|
||||
Source = "scanner",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = """{"scan_id":"scan-123"}"""
|
||||
};
|
||||
subscriber.Enqueue(evt);
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(300, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
store.ProcessedEvents.Should().ContainSingle(e => e.EventId == "evt-e2e-001");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_ProcessesMultipleEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TrackingStore();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = $"evt-order-{i:D3}",
|
||||
TenantId = "tenant-order",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow.AddSeconds(i),
|
||||
RawPayloadJson = $"{{\"seq\":{i}}}"
|
||||
});
|
||||
}
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(500, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - All events should be processed
|
||||
store.ProcessedEvents.Should().HaveCount(5);
|
||||
store.ProcessedEvents.Select(e => e.EventId).Should().BeEquivalentTo(
|
||||
new[] { "evt-order-001", "evt-order-002", "evt-order-003", "evt-order-004", "evt-order-005" });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_DeduplicatesEvents()
|
||||
{
|
||||
// Arrange
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TrackingStore();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var duplicateEvent = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-dup-001",
|
||||
TenantId = "tenant-dup",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
// Enqueue same event multiple times
|
||||
subscriber.Enqueue(duplicateEvent);
|
||||
subscriber.Enqueue(duplicateEvent);
|
||||
subscriber.Enqueue(duplicateEvent);
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(300, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - Only one should be stored
|
||||
store.UniqueInsertCount.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TIMELINE-5100-004: Retry Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_RetriesOnTransientFailure()
|
||||
{
|
||||
// Arrange
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TransientFailureStore(failCount: 2);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-retry-001",
|
||||
TenantId = "tenant-retry",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(1000, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - Should eventually succeed after retries
|
||||
store.TotalAttempts.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_ContinuesProcessingAfterFailure()
|
||||
{
|
||||
// Arrange
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new SelectiveFailureStore(failEventIds: new[] { "evt-fail" });
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-before",
|
||||
TenantId = "tenant-continue",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-fail", // This will fail
|
||||
TenantId = "tenant-continue",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-after",
|
||||
TenantId = "tenant-continue",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(500, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - Events before and after should be processed
|
||||
store.SuccessfulEvents.Should().Contain("evt-before");
|
||||
store.SuccessfulEvents.Should().Contain("evt-after");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TIMELINE-5100-005: OTel Correlation Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_PropagatesTraceContext()
|
||||
{
|
||||
// Arrange
|
||||
var capturedActivities = new List<Activity>();
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains("TimelineIndexer") || source.Name.Contains("StellaOps"),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
ActivityStarted = activity => capturedActivities.Add(activity)
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TrackingStore();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-trace-001",
|
||||
TenantId = "tenant-trace",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(300, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - If activities were captured, they should have proper context
|
||||
// Note: If no activities captured, the test documents expected behavior
|
||||
if (capturedActivities.Any())
|
||||
{
|
||||
capturedActivities.Should().AllSatisfy(a =>
|
||||
{
|
||||
a.TraceId.Should().NotBe(default(ActivityTraceId));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Worker_IncludesTenantInSpanTags()
|
||||
{
|
||||
// Arrange
|
||||
var capturedActivities = new List<Activity>();
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains("TimelineIndexer") || source.Name.Contains("StellaOps"),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
ActivityStarted = activity => capturedActivities.Add(activity)
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new TrackingStore();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
// Act
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
subscriber.Enqueue(new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-tenant-trace",
|
||||
TenantId = "tenant-traced",
|
||||
EventType = "test",
|
||||
Source = "test",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}"
|
||||
});
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(300, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
// Assert - Check for tenant tag in activities
|
||||
if (capturedActivities.Any())
|
||||
{
|
||||
var tenantActivities = capturedActivities
|
||||
.Where(a => a.Tags.Any(t => t.Key.Contains("tenant")))
|
||||
.ToList();
|
||||
|
||||
// Document expected behavior even if not yet implemented
|
||||
tenantActivities.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
|
||||
private sealed class TrackingStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
private readonly List<TimelineEventEnvelope> _processed = new();
|
||||
|
||||
public IReadOnlyList<TimelineEventEnvelope> ProcessedEvents => _processed;
|
||||
public int UniqueInsertCount => _seen.Count;
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_processed.Add(envelope);
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
var inserted = _seen.Add(key);
|
||||
return Task.FromResult(inserted);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TransientFailureStore : ITimelineEventStore
|
||||
{
|
||||
private readonly int _failCount;
|
||||
private int _attempts;
|
||||
|
||||
public int TotalAttempts => _attempts;
|
||||
|
||||
public TransientFailureStore(int failCount)
|
||||
{
|
||||
_failCount = failCount;
|
||||
}
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_attempts++;
|
||||
if (_attempts <= _failCount)
|
||||
{
|
||||
throw new InvalidOperationException($"Transient failure {_attempts}");
|
||||
}
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SelectiveFailureStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<string> _failEventIds;
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
private readonly List<string> _successful = new();
|
||||
|
||||
public IReadOnlyList<string> SuccessfulEvents => _successful;
|
||||
|
||||
public SelectiveFailureStore(string[] failEventIds)
|
||||
{
|
||||
_failEventIds = new HashSet<string>(failEventIds);
|
||||
}
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_failEventIds.Contains(envelope.EventId))
|
||||
{
|
||||
throw new InvalidOperationException($"Configured failure for {envelope.EventId}");
|
||||
}
|
||||
|
||||
var key = (envelope.TenantId, envelope.EventId);
|
||||
var inserted = _seen.Add(key);
|
||||
if (inserted)
|
||||
{
|
||||
_successful.Add(envelope.EventId);
|
||||
}
|
||||
return Task.FromResult(inserted);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
Reference in New Issue
Block a user