feat: Implement Filesystem and MongoDB provenance writers for PackRun execution context
- Added `FilesystemPackRunProvenanceWriter` to write provenance manifests to the filesystem. - Introduced `MongoPackRunArtifactReader` to read artifacts from MongoDB. - Created `MongoPackRunProvenanceWriter` to store provenance manifests in MongoDB. - Developed unit tests for filesystem and MongoDB provenance writers. - Established `ITimelineEventStore` and `ITimelineIngestionService` interfaces for timeline event handling. - Implemented `TimelineIngestionService` to validate and persist timeline events with hashing. - Created PostgreSQL schema and migration scripts for timeline indexing. - Added dependency injection support for timeline indexer services. - Developed tests for timeline ingestion and schema validation.
This commit is contained in:
@@ -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,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace StellaOps.TimelineIndexer.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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,20 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Query filters for timeline listing.
|
||||
/// </summary>
|
||||
public sealed class TimelineQueryOptions
|
||||
{
|
||||
public string? EventType { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public string? TraceId { 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,46 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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,29 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user