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