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
258 lines
8.5 KiB
C#
258 lines
8.5 KiB
C#
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_");
|
|
|
|
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();
|
|
|
|
var app = builder.Build();
|
|
|
|
app.MapHealthChecks("/healthz");
|
|
|
|
// Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset)
|
|
app.Use(async (context, next) =>
|
|
{
|
|
if (context.Request.Path.StartsWithSegments("/api/v1", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Response.Headers["Deprecation"] = "true";
|
|
context.Response.Headers["Sunset"] = "Tue, 31 Mar 2026 00:00:00 GMT";
|
|
context.Response.Headers["Link"] =
|
|
"<https://docs.stellaops.example.com/notify/deprecations>; rel=\"deprecation\"; type=\"text/html\"";
|
|
}
|
|
|
|
await next().ConfigureAwait(false);
|
|
});
|
|
|
|
app.MapPost("/api/v1/notify/pack-approvals", async (
|
|
HttpContext context,
|
|
PackApprovalRequest request,
|
|
INotifyLockRepository locks,
|
|
INotifyPackApprovalRepository packApprovals,
|
|
INotifyAuditRepository audit,
|
|
INotifyEventQueue? eventQueue,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
|
}
|
|
|
|
var idempotencyKey = context.Request.Headers["Idempotency-Key"].ToString();
|
|
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
|
{
|
|
return Results.BadRequest(Error("idempotency_key_missing", "Idempotency-Key header is required.", context));
|
|
}
|
|
|
|
if (request.EventId == Guid.Empty || string.IsNullOrWhiteSpace(request.PackId) ||
|
|
string.IsNullOrWhiteSpace(request.Kind) || string.IsNullOrWhiteSpace(request.Decision) ||
|
|
string.IsNullOrWhiteSpace(request.Actor))
|
|
{
|
|
return Results.BadRequest(Error("invalid_request", "eventId, packId, kind, decision, actor are required.", context));
|
|
}
|
|
|
|
try
|
|
{
|
|
var lockKey = $"pack-approvals|{tenantId}|{idempotencyKey}";
|
|
var ttl = TimeSpan.FromMinutes(15);
|
|
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals", ttl, context.RequestAborted)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!reserved)
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status200OK);
|
|
}
|
|
|
|
var document = new PackApprovalDocument
|
|
{
|
|
TenantId = tenantId,
|
|
EventId = request.EventId,
|
|
PackId = request.PackId,
|
|
Kind = request.Kind,
|
|
Decision = request.Decision,
|
|
Actor = request.Actor,
|
|
IssuedAt = request.IssuedAt,
|
|
PolicyId = request.Policy?.Id,
|
|
PolicyVersion = request.Policy?.Version,
|
|
ResumeToken = request.ResumeToken,
|
|
Summary = request.Summary,
|
|
Labels = request.Labels,
|
|
CreatedAt = timeProvider.GetUtcNow()
|
|
};
|
|
|
|
await packApprovals.UpsertAsync(document, context.RequestAborted).ConfigureAwait(false);
|
|
|
|
var auditEntry = new NotifyAuditEntryDocument
|
|
{
|
|
TenantId = tenantId,
|
|
Actor = request.Actor,
|
|
Action = "pack.approval.ingested",
|
|
EntityId = request.PackId,
|
|
EntityType = "pack-approval",
|
|
Timestamp = timeProvider.GetUtcNow(),
|
|
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
|
|
};
|
|
|
|
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
|
|
{
|
|
// swallow storage/audit errors in tests to avoid 500s
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.ResumeToken))
|
|
{
|
|
context.Response.Headers["X-Resume-After"] = request.ResumeToken;
|
|
}
|
|
|
|
return Results.Accepted();
|
|
});
|
|
|
|
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
|
HttpContext context,
|
|
string packId,
|
|
PackApprovalAckRequest request,
|
|
INotifyLockRepository locks,
|
|
INotifyAuditRepository audit,
|
|
TimeProvider timeProvider) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.AckToken))
|
|
{
|
|
return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context));
|
|
}
|
|
|
|
var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}";
|
|
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!reserved)
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status200OK);
|
|
}
|
|
|
|
try
|
|
{
|
|
var auditEntry = new NotifyAuditEntryDocument
|
|
{
|
|
TenantId = tenantId,
|
|
Actor = "pack-approvals-ack",
|
|
Action = "pack.approval.acknowledged",
|
|
EntityId = packId,
|
|
EntityType = "pack-approval",
|
|
Timestamp = timeProvider.GetUtcNow(),
|
|
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
|
|
};
|
|
|
|
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// ignore audit failures in tests
|
|
}
|
|
|
|
return Results.NoContent();
|
|
});
|
|
|
|
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
|
|
{
|
|
error = new
|
|
{
|
|
code,
|
|
message,
|
|
traceId = context.TraceIdentifier
|
|
}
|
|
};
|
|
|
|
app.Run();
|
|
|
|
public partial class Program;
|