work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -0,0 +1,256 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Setup;
/// <summary>
/// Seeds attestation templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class AttestationTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<AttestationTemplateSeeder> _logger;
public AttestationTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<AttestationTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping attestation template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} attestation templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping attestation routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default attestation routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateAttestationTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Attestation templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateAttestationRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Attestation rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
using var stream = File.OpenRead(samplePath);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.GetProperty("enabled").GetBoolean();
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded attestation routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:attestation");
}
private static string? LocateAttestationTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "attestation"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "attestation")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateAttestationRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "attestation-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "docs", "attestation-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}

View File

@@ -0,0 +1,258 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Xml;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.WebService.Setup;
/// <summary>
/// Seeds risk templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class RiskTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<RiskTemplateSeeder> _logger;
public RiskTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<RiskTemplateSeeder> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var templateRepo = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepo = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var ruleRepo = scope.ServiceProvider.GetService<INotifyRuleRepository>();
if (templateRepo is null)
{
_logger.LogWarning("Template repository not registered; skipping risk template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var templatesSeeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (templatesSeeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} risk templates from offline bundle.", templatesSeeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping risk routing seed.");
return;
}
var routingSeeded = await SeedRoutingAsync(channelRepo, ruleRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (routingSeeded > 0)
{
_logger.LogInformation("Seeded default risk routing (channels + rules).");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public static async Task<int> SeedTemplatesAsync(
INotifyTemplateRepository repository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
var templateDir = LocateRiskTemplatesPath(contentRootPath);
if (templateDir is null)
{
logger.LogWarning("Risk templates directory not found under {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
var count = 0;
foreach (var file in Directory.EnumerateFiles(templateDir, "*.template.json", SearchOption.TopDirectoryOnly))
{
try
{
var template = await ToTemplateAsync(file, cancellationToken).ConfigureAwait(false);
await repository.UpsertAsync(template, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template from {File}.", file);
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
string contentRootPath,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
var samplePath = LocateRiskRulesPath(contentRootPath);
if (samplePath is null)
{
logger.LogWarning("Risk rules sample not found under {ContentRoot}; skipping routing seed.", contentRootPath);
return 0;
}
await using var stream = File.OpenRead(samplePath);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var tenant = "bootstrap";
var channelsElement = doc.RootElement.GetProperty("channels");
var rulesElement = doc.RootElement.GetProperty("rules");
var channels = channelsElement.EnumerateArray()
.Select(ToChannel)
.ToArray();
foreach (var channel in channels)
{
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
}
foreach (var rule in rulesElement.EnumerateArray())
{
var model = ToRule(rule, tenant);
await ruleRepository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
}
return channels.Length + rulesElement.GetArrayLength();
}
private static NotifyRule ToRule(JsonElement element, string tenant)
{
var ruleId = element.GetProperty("ruleId").GetString() ?? throw new InvalidOperationException("ruleId missing");
var name = element.GetProperty("name").GetString() ?? ruleId;
var enabled = element.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true;
var matchKinds = element.GetProperty("match").GetProperty("eventKinds").EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray();
var actions = element.GetProperty("actions").EnumerateArray().Select(action =>
NotifyRuleAction.Create(
actionId: action.GetProperty("actionId").GetString() ?? throw new InvalidOperationException("actionId missing"),
channel: action.GetProperty("channel").GetString() ?? string.Empty,
template: action.GetProperty("template").GetString() ?? string.Empty,
locale: action.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
throttle: action.TryGetProperty("throttle", out var throttle) ? XmlConvert.ToTimeSpan(throttle.GetString() ?? string.Empty) : default,
enabled: action.TryGetProperty("enabled", out var en) ? en.GetBoolean() : true)).ToArray();
return NotifyRule.Create(
ruleId: ruleId,
tenantId: tenant,
name: name,
match: NotifyRuleMatch.Create(eventKinds: matchKinds),
actions: actions,
enabled: enabled,
description: "Seeded risk routing rule.");
}
private static NotifyChannel ToChannel(JsonElement element)
{
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
var name = element.GetProperty("name").GetString() ?? channelId;
var target = element.TryGetProperty("target", out var t) ? t.GetString() : null;
var endpoint = element.TryGetProperty("endpoint", out var e) ? e.GetString() : null;
var secretRef = element.GetProperty("secretRef").GetString() ?? string.Empty;
var config = NotifyChannelConfig.Create(
secretRef: secretRef,
endpoint: endpoint,
target: target);
return NotifyChannel.Create(
channelId: channelId,
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
name: name,
type: type,
config: config,
description: element.TryGetProperty("description", out var d) ? d.GetString() : null);
}
private static async Task<NotifyTemplate> ToTemplateAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = doc.RootElement;
var templateId = root.GetProperty("templateId").GetString() ?? Path.GetFileNameWithoutExtension(path);
var tenantId = root.GetProperty("tenantId").GetString() ?? "bootstrap";
var channelType = ParseEnum<NotifyChannelType>(root.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var key = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = root.GetProperty("locale").GetString() ?? "en-US";
var renderMode = ParseEnum<NotifyTemplateRenderMode>(root.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(root.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var body = root.GetProperty("body").GetString() ?? string.Empty;
var metadata = Enumerable.Empty<KeyValuePair<string, string>>();
if (root.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
{
metadata = meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty));
}
return NotifyTemplate.Create(
templateId: templateId,
tenantId: tenantId,
channelType: channelType,
key: key,
locale: locale,
body: body,
renderMode: renderMode,
format: format,
description: description,
metadata: metadata,
createdBy: "seed:risk");
}
private static string? LocateRiskTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "offline", "notifier", "templates", "risk"),
Path.Combine(contentRootPath, "..", "offline", "notifier", "templates", "risk")
};
return candidates.FirstOrDefault(Directory.Exists);
}
private static string? LocateRiskRulesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "risk-rules.sample.json"),
Path.Combine(contentRootPath, "src", "Notifier", "StellaOps.Notifier", "StellaOps.Notifier.docs", "risk-rules.sample.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct
{
if (!string.IsNullOrWhiteSpace(value) && Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
return fallback;
}
}