up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Symbols Server CI / symbols-smoke (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 20:57:49 +02:00
parent 46c8c47d06
commit 7c39058386
92 changed files with 3549 additions and 157 deletions

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Queue;
namespace StellaOps.Notifier.WebService.Setup;
/// <summary>
/// No-op event queue used when a real queue backend is not configured (dev/test/offline).
/// </summary>
public sealed class NullNotifyEventQueue : INotifyEventQueue
{
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(new NotifyQueueEnqueueResult("null", false));
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default) =>
ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}

View File

@@ -0,0 +1,230 @@
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
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 pack-approval templates and default routing for dev/test/bootstrap scenarios.
/// </summary>
public sealed class PackApprovalTemplateSeeder : IHostedService
{
private readonly IServiceProvider _services;
private readonly IHostEnvironment _environment;
private readonly ILogger<PackApprovalTemplateSeeder> _logger;
public PackApprovalTemplateSeeder(IServiceProvider services, IHostEnvironment environment, ILogger<PackApprovalTemplateSeeder> 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 pack-approval template seed.");
return;
}
var contentRoot = _environment.ContentRootPath;
var seeded = await SeedTemplatesAsync(templateRepo, contentRoot, _logger, cancellationToken).ConfigureAwait(false);
if (seeded > 0)
{
_logger.LogInformation("Seeded {TemplateCount} pack-approval templates from docs.", seeded);
}
if (channelRepo is null || ruleRepo is null)
{
_logger.LogWarning("Channel or rule repository not registered; skipping pack-approval routing seed.");
return;
}
var routed = await SeedRoutingAsync(channelRepo, ruleRepo, _logger, cancellationToken).ConfigureAwait(false);
if (routed > 0)
{
_logger.LogInformation("Seeded default pack-approval routing (channels + rule).");
}
}
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 path = LocateTemplatesPath(contentRootPath);
if (path is null)
{
logger.LogWarning("pack-approval-templates.json not found under content root {ContentRoot}; skipping seed.", contentRootPath);
return 0;
}
using var stream = File.OpenRead(path);
using var document = JsonDocument.Parse(stream);
if (!document.RootElement.TryGetProperty("templates", out var templatesElement))
{
logger.LogWarning("pack-approval-templates.json missing 'templates' array; skipping seed.");
return 0;
}
var count = 0;
foreach (var template in templatesElement.EnumerateArray())
{
try
{
var model = ToTemplate(template);
await repository.UpsertAsync(model, cancellationToken).ConfigureAwait(false);
count++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to seed template entry; skipping.");
}
}
return count;
}
public static async Task<int> SeedRoutingAsync(
INotifyChannelRepository channelRepository,
INotifyRuleRepository ruleRepository,
ILogger logger,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channelRepository);
ArgumentNullException.ThrowIfNull(ruleRepository);
ArgumentNullException.ThrowIfNull(logger);
const string tenant = "tenant-sample";
var slackChannel = NotifyChannel.Create(
channelId: "chn-pack-approvals-slack",
tenantId: tenant,
name: "Slack · Pack Approvals",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/pack-approvals",
endpoint: "https://hooks.slack.local/services/T000/B000/DEV",
target: "#pack-approvals"),
description: "Default Slack channel for pack approval notifications.");
var emailChannel = NotifyChannel.Create(
channelId: "chn-pack-approvals-email",
tenantId: tenant,
name: "Email · Pack Approvals",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/pack-approvals",
target: "pack-approvals@example.com"),
description: "Default email channel for pack approval notifications.");
await channelRepository.UpsertAsync(slackChannel, cancellationToken).ConfigureAwait(false);
await channelRepository.UpsertAsync(emailChannel, cancellationToken).ConfigureAwait(false);
var rule = NotifyRule.Create(
ruleId: "rule-pack-approvals-default",
tenantId: tenant,
name: "Pack approvals → Slack + Email",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "pack.approval.granted", "pack.approval.denied", "pack.policy.override" },
labels: new[] { "environment=prod" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-pack-approvals-slack",
channel: slackChannel.ChannelId,
template: "tmpl-pack-approval-slack-en",
locale: "en-US"),
NotifyRuleAction.Create(
actionId: "act-pack-approvals-email",
channel: emailChannel.ChannelId,
template: "tmpl-pack-approval-email-en",
locale: "en-US")
},
description: "Routes pack approval events to seeded Slack and Email channels.");
await ruleRepository.UpsertAsync(rule, cancellationToken).ConfigureAwait(false);
return 3; // two channels + one rule
}
private static string? LocateTemplatesPath(string contentRootPath)
{
var candidates = new[]
{
Path.Combine(contentRootPath, "StellaOps.Notifier.docs", "pack-approval-templates.json"),
Path.Combine(contentRootPath, "..", "StellaOps.Notifier.docs", "pack-approval-templates.json")
};
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return Path.GetFullPath(candidate);
}
}
return null;
}
private static NotifyTemplate ToTemplate(JsonElement element)
{
var templateId = element.GetProperty("templateId").GetString() ?? throw new InvalidOperationException("templateId missing");
var tenantId = element.GetProperty("tenantId").GetString() ?? throw new InvalidOperationException("tenantId missing");
var key = element.GetProperty("key").GetString() ?? throw new InvalidOperationException("key missing");
var locale = element.GetProperty("locale").GetString() ?? "en-US";
var body = element.GetProperty("body").GetString() ?? string.Empty;
var channelType = ParseEnum<NotifyChannelType>(element.GetProperty("channelType").GetString(), NotifyChannelType.Custom);
var renderMode = ParseEnum<NotifyTemplateRenderMode>(element.GetProperty("renderMode").GetString(), NotifyTemplateRenderMode.Markdown);
var format = ParseEnum<NotifyDeliveryFormat>(element.GetProperty("format").GetString(), NotifyDeliveryFormat.Json);
var description = element.TryGetProperty("description", out var desc) ? desc.GetString() : null;
var metadata = element.TryGetProperty("metadata", out var meta)
? meta.EnumerateObject().Select(p => new KeyValuePair<string, string>(p.Name, p.Value.GetString() ?? string.Empty))
: Enumerable.Empty<KeyValuePair<string, string>>();
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:pack-approvals");
}
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;
}
}