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:
@@ -1,28 +1,42 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Queue;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var isTesting = builder.Environment.IsEnvironment("Testing");
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "NOTIFIER_");
|
||||
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
// OpenAPI cache resolved inline for simplicity in tests
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
|
||||
if (!isTesting)
|
||||
{
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
builder.Services.AddHostedService<PackApprovalTemplateSeeder>();
|
||||
}
|
||||
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -48,6 +62,7 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
INotifyLockRepository locks,
|
||||
INotifyPackApprovalRepository packApprovals,
|
||||
INotifyAuditRepository audit,
|
||||
INotifyEventQueue? eventQueue,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
@@ -112,6 +127,38 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
};
|
||||
|
||||
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (eventQueue is not null)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToNode(new
|
||||
{
|
||||
request.PackId,
|
||||
request.Kind,
|
||||
request.Decision,
|
||||
request.Policy,
|
||||
request.ResumeToken,
|
||||
request.Summary,
|
||||
request.Labels
|
||||
}) ?? new JsonObject();
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: request.EventId != Guid.Empty ? request.EventId : Guid.NewGuid(),
|
||||
kind: request.Kind ?? "pack.approval",
|
||||
tenant: tenantId,
|
||||
ts: request.IssuedAt != default ? request.IssuedAt : timeProvider.GetUtcNow(),
|
||||
payload: payload,
|
||||
actor: request.Actor,
|
||||
version: "1");
|
||||
|
||||
await eventQueue.PublishAsync(
|
||||
new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
stream: "notify:events",
|
||||
idempotencyKey: lockKey,
|
||||
partitionKey: tenantId,
|
||||
traceId: context.TraceIdentifier),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -177,7 +224,23 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/.well-known/openapi", () => Results.Content("# notifier openapi stub\nopenapi: 3.1.0\npaths: {}", "application/yaml"));
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
|
||||
context.Response.Headers.ETag = "\"notifier-oas-stub\"";
|
||||
|
||||
const string stub = """
|
||||
# notifier openapi stub
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Notifier
|
||||
paths:
|
||||
/api/v1/notify/quiet-hours: {}
|
||||
/api/v1/notify/incidents: {}
|
||||
""";
|
||||
|
||||
return Results.Text(stub, "application/yaml", Encoding.UTF8);
|
||||
});
|
||||
|
||||
static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user