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:
2025-10-19 18:36:22 +03:00
parent 7e2fa0a42a
commit 5ce40d2eeb
966 changed files with 91038 additions and 1850 deletions

View File

@@ -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; }