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
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:
@@ -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>>());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user