feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
		| @@ -1,12 +1,11 @@ | ||||
| using System.Globalization; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Concelier.Connector.Common; | ||||
| using StellaOps.Concelier.Connector.Common.Fetch; | ||||
| using StellaOps.Concelier.Connector.Common.State; | ||||
| using StellaOps.Concelier.Storage.Mongo; | ||||
| using StellaOps.Concelier.Storage.Mongo.Documents; | ||||
|  | ||||
| @@ -40,58 +39,28 @@ internal static class Program | ||||
|                 return 1; | ||||
|             } | ||||
|  | ||||
|             var specification = await BuildSpecificationAsync(seed, sourceName, options.InputPath, CancellationToken.None).ConfigureAwait(false); | ||||
|  | ||||
|             var client = new MongoClient(options.ConnectionString); | ||||
|             var database = client.GetDatabase(options.DatabaseName); | ||||
|  | ||||
|             var loggerFactory = NullLoggerFactory.Instance; | ||||
|  | ||||
|             var documentStore = new DocumentStore(database, loggerFactory.CreateLogger<DocumentStore>()); | ||||
|             var rawStorage = new RawDocumentStorage(database); | ||||
|             var stateRepository = new MongoSourceStateRepository(database, loggerFactory.CreateLogger<MongoSourceStateRepository>()); | ||||
|  | ||||
|             var pendingDocumentIds = new List<Guid>(); | ||||
|             var pendingMappingIds = new List<Guid>(); | ||||
|             var knownAdvisories = new List<string>(); | ||||
|  | ||||
|             var now = DateTimeOffset.UtcNow; | ||||
|             var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(options.InputPath)) ?? Directory.GetCurrentDirectory(); | ||||
|  | ||||
|             foreach (var document in seed.Documents) | ||||
|             { | ||||
|                 var (record, addedToPendingDocs, addedToPendingMaps, known) = await UpsertDocumentAsync( | ||||
|                     documentStore, | ||||
|                     rawStorage, | ||||
|                     sourceName, | ||||
|                     baseDirectory, | ||||
|                     now, | ||||
|                     document, | ||||
|                     cancellationToken: default).ConfigureAwait(false); | ||||
|  | ||||
|                 if (addedToPendingDocs) | ||||
|                 { | ||||
|                     pendingDocumentIds.Add(record.Id); | ||||
|                 } | ||||
|  | ||||
|                 if (addedToPendingMaps) | ||||
|                 { | ||||
|                     pendingMappingIds.Add(record.Id); | ||||
|                 } | ||||
|  | ||||
|                 if (known is not null) | ||||
|                 { | ||||
|                     knownAdvisories.AddRange(known); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             await UpdateCursorAsync( | ||||
|             var processor = new SourceStateSeedProcessor( | ||||
|                 documentStore, | ||||
|                 rawStorage, | ||||
|                 stateRepository, | ||||
|                 sourceName, | ||||
|                 seed.Cursor, | ||||
|                 pendingDocumentIds, | ||||
|                 pendingMappingIds, | ||||
|                 knownAdvisories, | ||||
|                 now).ConfigureAwait(false); | ||||
|                 TimeProvider.System, | ||||
|                 loggerFactory.CreateLogger<SourceStateSeedProcessor>()); | ||||
|  | ||||
|             Console.WriteLine($"Seeded {pendingDocumentIds.Count + pendingMappingIds.Count} documents for {sourceName}."); | ||||
|             var result = await processor.ProcessAsync(specification, CancellationToken.None).ConfigureAwait(false); | ||||
|  | ||||
|             Console.WriteLine( | ||||
|                 $"Seeded {result.DocumentsProcessed} document(s) for {sourceName} " + | ||||
|                 $"(pendingDocuments+= {result.PendingDocumentsAdded}, pendingMappings+= {result.PendingMappingsAdded}, knownAdvisories+= {result.KnownAdvisoriesAdded.Count})."); | ||||
|             return 0; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
| @@ -109,13 +78,33 @@ internal static class Program | ||||
|         return seed; | ||||
|     } | ||||
|  | ||||
|     private static async Task<(DocumentRecord Record, bool PendingDoc, bool PendingMap, IReadOnlyCollection<string>? Known)> UpsertDocumentAsync( | ||||
|         DocumentStore documentStore, | ||||
|         RawDocumentStorage rawStorage, | ||||
|     private static async Task<SourceStateSeedSpecification> BuildSpecificationAsync( | ||||
|         StateSeed seed, | ||||
|         string sourceName, | ||||
|         string baseDirectory, | ||||
|         DateTimeOffset fetchedAt, | ||||
|         string inputPath, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(inputPath)) ?? Directory.GetCurrentDirectory(); | ||||
|         var documents = new List<SourceStateSeedDocument>(seed.Documents.Count); | ||||
|  | ||||
|         foreach (var documentSeed in seed.Documents) | ||||
|         { | ||||
|             documents.Add(await BuildDocumentAsync(documentSeed, baseDirectory, cancellationToken).ConfigureAwait(false)); | ||||
|         } | ||||
|  | ||||
|         return new SourceStateSeedSpecification | ||||
|         { | ||||
|             Source = sourceName, | ||||
|             Documents = documents.AsReadOnly(), | ||||
|             Cursor = BuildCursor(seed.Cursor), | ||||
|             KnownAdvisories = NormalizeStrings(seed.KnownAdvisories), | ||||
|             CompletedAt = seed.CompletedAt, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static async Task<SourceStateSeedDocument> BuildDocumentAsync( | ||||
|         DocumentSeed seed, | ||||
|         string baseDirectory, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(seed.Uri)) | ||||
| @@ -128,152 +117,120 @@ internal static class Program | ||||
|             throw new InvalidOperationException($"Seed entry for '{seed.Uri}' missing 'contentFile'."); | ||||
|         } | ||||
|  | ||||
|         var contentPath = Path.IsPathRooted(seed.ContentFile) | ||||
|             ? seed.ContentFile | ||||
|             : Path.GetFullPath(Path.Combine(baseDirectory, seed.ContentFile)); | ||||
|  | ||||
|         var contentPath = ResolvePath(seed.ContentFile, baseDirectory); | ||||
|         if (!File.Exists(contentPath)) | ||||
|         { | ||||
|             throw new FileNotFoundException($"Content file not found for '{seed.Uri}'.", contentPath); | ||||
|         } | ||||
|  | ||||
|         var contentBytes = await File.ReadAllBytesAsync(contentPath, cancellationToken).ConfigureAwait(false); | ||||
|         var sha256 = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(); | ||||
|         var gridId = await rawStorage.UploadAsync( | ||||
|             sourceName, | ||||
|             seed.Uri, | ||||
|             contentBytes, | ||||
|             seed.ContentType, | ||||
|             seed.ExpiresAt, | ||||
|             cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var metadata = seed.Metadata is null | ||||
|             ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|             ? null | ||||
|             : new Dictionary<string, string>(seed.Metadata, StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         var headers = seed.Headers is null | ||||
|             ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | ||||
|             ? null | ||||
|             : new Dictionary<string, string>(seed.Headers, StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         if (!headers.ContainsKey("content-type") && !string.IsNullOrWhiteSpace(seed.ContentType)) | ||||
|         if (!string.IsNullOrWhiteSpace(seed.ContentType)) | ||||
|         { | ||||
|             headers["content-type"] = seed.ContentType!; | ||||
|             headers ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||||
|             if (!headers.ContainsKey("content-type")) | ||||
|             { | ||||
|                 headers["content-type"] = seed.ContentType!; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var lastModified = seed.LastModified is null | ||||
|             ? (DateTimeOffset?)null | ||||
|             : DateTimeOffset.Parse(seed.LastModified, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); | ||||
|  | ||||
|         var record = new DocumentRecord( | ||||
|             Guid.NewGuid(), | ||||
|             sourceName, | ||||
|             seed.Uri, | ||||
|             fetchedAt, | ||||
|             sha256, | ||||
|             string.IsNullOrWhiteSpace(seed.Status) ? DocumentStatuses.PendingParse : seed.Status, | ||||
|             seed.ContentType, | ||||
|             headers, | ||||
|             metadata, | ||||
|             seed.Etag, | ||||
|             lastModified, | ||||
|             gridId, | ||||
|             seed.ExpiresAt); | ||||
|  | ||||
|         var upserted = await documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         return (upserted, seed.AddToPendingDocuments, seed.AddToPendingMappings, seed.KnownIdentifiers); | ||||
|         return new SourceStateSeedDocument | ||||
|         { | ||||
|             Uri = seed.Uri, | ||||
|             DocumentId = seed.DocumentId, | ||||
|             Content = contentBytes, | ||||
|             ContentType = seed.ContentType, | ||||
|             Status = string.IsNullOrWhiteSpace(seed.Status) ? DocumentStatuses.PendingParse : seed.Status, | ||||
|             Headers = headers, | ||||
|             Metadata = metadata, | ||||
|             Etag = seed.Etag, | ||||
|             LastModified = ParseOptionalDate(seed.LastModified), | ||||
|             ExpiresAt = seed.ExpiresAt, | ||||
|             FetchedAt = ParseOptionalDate(seed.FetchedAt), | ||||
|             AddToPendingDocuments = seed.AddToPendingDocuments, | ||||
|             AddToPendingMappings = seed.AddToPendingMappings, | ||||
|             KnownIdentifiers = NormalizeStrings(seed.KnownIdentifiers), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static async Task UpdateCursorAsync( | ||||
|         ISourceStateRepository repository, | ||||
|         string sourceName, | ||||
|         CursorSeed? cursorSeed, | ||||
|         IReadOnlyCollection<Guid> pendingDocuments, | ||||
|         IReadOnlyCollection<Guid> pendingMappings, | ||||
|         IReadOnlyCollection<string> knownAdvisories, | ||||
|         DateTimeOffset completedAt) | ||||
|     private static SourceStateSeedCursor? BuildCursor(CursorSeed? cursorSeed) | ||||
|     { | ||||
|         var state = await repository.TryGetAsync(sourceName, CancellationToken.None).ConfigureAwait(false); | ||||
|         var cursor = state?.Cursor ?? new BsonDocument(); | ||||
|  | ||||
|         MergeGuidArray(cursor, "pendingDocuments", pendingDocuments); | ||||
|         MergeGuidArray(cursor, "pendingMappings", pendingMappings); | ||||
|  | ||||
|         if (knownAdvisories.Count > 0) | ||||
|         if (cursorSeed is null) | ||||
|         { | ||||
|             MergeStringArray(cursor, "knownAdvisories", knownAdvisories); | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (cursorSeed is not null) | ||||
|         return new SourceStateSeedCursor | ||||
|         { | ||||
|             if (cursorSeed.LastModifiedCursor.HasValue) | ||||
|             { | ||||
|                 cursor["lastModifiedCursor"] = cursorSeed.LastModifiedCursor.Value.UtcDateTime; | ||||
|             } | ||||
|  | ||||
|             if (cursorSeed.LastFetchAt.HasValue) | ||||
|             { | ||||
|                 cursor["lastFetchAt"] = cursorSeed.LastFetchAt.Value.UtcDateTime; | ||||
|             } | ||||
|  | ||||
|             if (cursorSeed.Additional is not null) | ||||
|             { | ||||
|                 foreach (var kvp in cursorSeed.Additional) | ||||
|                 { | ||||
|                     cursor[kvp.Key] = kvp.Value; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         cursor["lastSeededAt"] = completedAt.UtcDateTime; | ||||
|  | ||||
|         await repository.UpdateCursorAsync(sourceName, cursor, completedAt, CancellationToken.None).ConfigureAwait(false); | ||||
|             PendingDocuments = NormalizeGuids(cursorSeed.PendingDocuments), | ||||
|             PendingMappings = NormalizeGuids(cursorSeed.PendingMappings), | ||||
|             KnownAdvisories = NormalizeStrings(cursorSeed.KnownAdvisories), | ||||
|             LastModifiedCursor = cursorSeed.LastModifiedCursor, | ||||
|             LastFetchAt = cursorSeed.LastFetchAt, | ||||
|             Additional = cursorSeed.Additional is null | ||||
|                 ? null | ||||
|                 : new Dictionary<string, string>(cursorSeed.Additional, StringComparer.OrdinalIgnoreCase), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static void MergeGuidArray(BsonDocument cursor, string field, IReadOnlyCollection<Guid> values) | ||||
|     private static IReadOnlyCollection<Guid>? NormalizeGuids(IEnumerable<Guid>? values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         if (values is null) | ||||
|         { | ||||
|             return; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var existing = cursor.TryGetValue(field, out var value) && value is BsonArray array | ||||
|             ? array.Select(v => Guid.TryParse(v?.AsString, out var parsed) ? parsed : Guid.Empty) | ||||
|                 .Where(g => g != Guid.Empty) | ||||
|                 .ToHashSet() | ||||
|             : new HashSet<Guid>(); | ||||
|  | ||||
|         var set = new HashSet<Guid>(); | ||||
|         foreach (var guid in values) | ||||
|         { | ||||
|             existing.Add(guid); | ||||
|         } | ||||
|  | ||||
|         cursor[field] = new BsonArray(existing.Select(g => g.ToString())); | ||||
|     } | ||||
|  | ||||
|     private static void MergeStringArray(BsonDocument cursor, string field, IReadOnlyCollection<string> values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var existing = cursor.TryGetValue(field, out var value) && value is BsonArray array | ||||
|             ? array.Select(v => v?.AsString ?? string.Empty) | ||||
|                 .Where(s => !string.IsNullOrWhiteSpace(s)) | ||||
|                 .ToHashSet(StringComparer.OrdinalIgnoreCase) | ||||
|             : new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         foreach (var entry in values) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(entry)) | ||||
|             if (guid != Guid.Empty) | ||||
|             { | ||||
|                 existing.Add(entry.Trim()); | ||||
|                 set.Add(guid); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         cursor[field] = new BsonArray(existing.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); | ||||
|         return set.Count == 0 ? null : set.ToList(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<string>? NormalizeStrings(IEnumerable<string>? values) | ||||
|     { | ||||
|         if (values is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 set.Add(value.Trim()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return set.Count == 0 ? null : set.ToList(); | ||||
|     } | ||||
|  | ||||
|     private static DateTimeOffset? ParseOptionalDate(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); | ||||
|     } | ||||
|  | ||||
|     private static string ResolvePath(string path, string baseDirectory) | ||||
|         => Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(baseDirectory, path)); | ||||
| } | ||||
|  | ||||
| internal sealed record SeedOptions | ||||
| @@ -356,12 +313,15 @@ internal sealed record StateSeed | ||||
|     public string? Source { get; init; } | ||||
|     public List<DocumentSeed> Documents { get; init; } = new(); | ||||
|     public CursorSeed? Cursor { get; init; } | ||||
|     public List<string>? KnownAdvisories { get; init; } | ||||
|     public DateTimeOffset? CompletedAt { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed record DocumentSeed | ||||
| { | ||||
|     public string Uri { get; init; } = string.Empty; | ||||
|     public string ContentFile { get; init; } = string.Empty; | ||||
|     public Guid? DocumentId { get; init; } | ||||
|     public string? ContentType { get; init; } | ||||
|     public Dictionary<string, string>? Metadata { get; init; } | ||||
|     public Dictionary<string, string>? Headers { get; init; } | ||||
| @@ -369,13 +329,17 @@ internal sealed record DocumentSeed | ||||
|     public bool AddToPendingDocuments { get; init; } = true; | ||||
|     public bool AddToPendingMappings { get; init; } | ||||
|     public string? LastModified { get; init; } | ||||
|     public string? FetchedAt { get; init; } | ||||
|     public string? Etag { get; init; } | ||||
|     public DateTimeOffset? ExpiresAt { get; init; } | ||||
|     public IReadOnlyCollection<string>? KnownIdentifiers { get; init; } | ||||
|     public List<string>? KnownIdentifiers { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed record CursorSeed | ||||
| { | ||||
|     public List<Guid>? PendingDocuments { get; init; } | ||||
|     public List<Guid>? PendingMappings { get; init; } | ||||
|     public List<string>? KnownAdvisories { get; init; } | ||||
|     public DateTimeOffset? LastModifiedCursor { get; init; } | ||||
|     public DateTimeOffset? LastFetchAt { get; init; } | ||||
|     public Dictionary<string, string>? Additional { get; init; } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user