Refactor code structure for improved readability and maintainability
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-06 21:48:12 +02:00
parent f6c22854a4
commit dd0067ea0b
105 changed files with 12662 additions and 427 deletions

View File

@@ -0,0 +1,196 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Middleware that adds deprecation and sunset headers per RFC 8594.
/// </summary>
public sealed class ApiDeprecationMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
private readonly ILogger<ApiDeprecationMiddleware> _logger;
private readonly List<CompiledEndpointPattern> _patterns;
/// <summary>
/// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header.
/// </summary>
public const string DeprecationHeader = "Deprecation";
/// <summary>
/// HTTP header for sunset date per RFC 8594.
/// </summary>
public const string SunsetHeader = "Sunset";
/// <summary>
/// HTTP Link header for deprecation documentation.
/// </summary>
public const string LinkHeader = "Link";
public ApiDeprecationMiddleware(
RequestDelegate next,
IOptionsMonitor<ApiDeprecationOptions> options,
ILogger<ApiDeprecationMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints);
options.OnChange(newOptions =>
{
_patterns.Clear();
_patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints));
});
}
public async Task InvokeAsync(HttpContext context)
{
var options = _options.CurrentValue;
var path = context.Request.Path.Value ?? string.Empty;
var deprecatedEndpoint = FindMatchingEndpoint(path);
if (deprecatedEndpoint is not null)
{
AddDeprecationHeaders(context.Response, deprecatedEndpoint, options);
_logger.LogInformation(
"Deprecated endpoint accessed: {Path} (sunset: {Sunset})",
path,
deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set");
}
await _next(context).ConfigureAwait(false);
}
private CompiledEndpointPattern? FindMatchingEndpoint(string path)
{
foreach (var pattern in _patterns)
{
if (pattern.Regex.IsMatch(path))
{
return pattern;
}
}
return null;
}
private static void AddDeprecationHeaders(
HttpResponse response,
CompiledEndpointPattern endpoint,
ApiDeprecationOptions options)
{
var config = endpoint.Config;
// Add Deprecation header per draft-ietf-httpapi-deprecation-header
if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue)
{
// RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT
var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(DeprecationHeader, deprecationDate);
}
else if (options.EmitDeprecationHeaders)
{
// If no specific date, use "true" to indicate deprecated
response.Headers.Append(DeprecationHeader, "true");
}
// Add Sunset header per RFC 8594
if (options.EmitSunsetHeaders && config.SunsetAt.HasValue)
{
var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(SunsetHeader, sunsetDate);
}
// Add Link headers for documentation
var links = new List<string>();
if (!string.IsNullOrWhiteSpace(config.DeprecationLink))
{
links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl))
{
links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(config.ReplacementPath))
{
links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\"");
}
if (links.Count > 0)
{
response.Headers.Append(LinkHeader, string.Join(", ", links));
}
// Add custom deprecation message header
if (!string.IsNullOrWhiteSpace(config.Message))
{
response.Headers.Append("X-Deprecation-Notice", config.Message);
}
}
private static List<CompiledEndpointPattern> CompilePatterns(List<DeprecatedEndpoint> endpoints)
{
var patterns = new List<CompiledEndpointPattern>(endpoints.Count);
foreach (var endpoint in endpoints)
{
if (string.IsNullOrWhiteSpace(endpoint.PathPattern))
{
continue;
}
// Convert wildcard pattern to regex
var pattern = "^" + Regex.Escape(endpoint.PathPattern)
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
try
{
var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
patterns.Add(new CompiledEndpointPattern(regex, endpoint));
}
catch (ArgumentException)
{
// Invalid regex pattern, skip
}
}
return patterns;
}
private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config);
}
/// <summary>
/// Extension methods for adding API deprecation middleware.
/// </summary>
public static class ApiDeprecationMiddlewareExtensions
{
/// <summary>
/// Adds the API deprecation middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app)
{
return app.UseMiddleware<ApiDeprecationMiddleware>();
}
/// <summary>
/// Adds API deprecation services to the service collection.
/// </summary>
public static IServiceCollection AddApiDeprecation(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<ApiDeprecationOptions>(
configuration.GetSection(ApiDeprecationOptions.SectionName));
return services;
}
}

View File

@@ -0,0 +1,68 @@
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Configuration options for API deprecation and sunset headers.
/// </summary>
public sealed class ApiDeprecationOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "TaskRunner:ApiDeprecation";
/// <summary>
/// Whether to emit deprecation headers for deprecated endpoints.
/// </summary>
public bool EmitDeprecationHeaders { get; set; } = true;
/// <summary>
/// Whether to emit sunset headers per RFC 8594.
/// </summary>
public bool EmitSunsetHeaders { get; set; } = true;
/// <summary>
/// URL to deprecation policy documentation.
/// </summary>
public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy";
/// <summary>
/// List of deprecated endpoints with their sunset dates.
/// </summary>
public List<DeprecatedEndpoint> DeprecatedEndpoints { get; set; } = [];
}
/// <summary>
/// Configuration for a deprecated endpoint.
/// </summary>
public sealed class DeprecatedEndpoint
{
/// <summary>
/// Path pattern to match (supports wildcards like /v1/packs/*).
/// </summary>
public string PathPattern { get; set; } = string.Empty;
/// <summary>
/// Date when the endpoint was deprecated.
/// </summary>
public DateTimeOffset? DeprecatedAt { get; set; }
/// <summary>
/// Date when the endpoint will be removed (sunset date per RFC 8594).
/// </summary>
public DateTimeOffset? SunsetAt { get; set; }
/// <summary>
/// URL to documentation about the deprecation and migration path.
/// </summary>
public string? DeprecationLink { get; set; }
/// <summary>
/// Suggested replacement endpoint path.
/// </summary>
public string? ReplacementPath { get; set; }
/// <summary>
/// Human-readable deprecation message.
/// </summary>
public string? Message { get; set; }
}

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Options;
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Service for sending deprecation notifications to API consumers.
/// </summary>
public interface IDeprecationNotificationService
{
/// <summary>
/// Sends a notification about an upcoming deprecation.
/// </summary>
/// <param name="notification">Deprecation notification details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Gets upcoming deprecations within a specified number of days.
/// </summary>
/// <param name="withinDays">Number of days to look ahead.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of upcoming deprecations.</returns>
Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
int withinDays = 90,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Deprecation notification details.
/// </summary>
public sealed record DeprecationNotification(
string EndpointPath,
string? ReplacementPath,
DateTimeOffset? SunsetDate,
string? Message,
string? DocumentationUrl,
IReadOnlyList<string>? AffectedConsumerIds);
/// <summary>
/// Information about a deprecation.
/// </summary>
public sealed record DeprecationInfo(
string EndpointPath,
DateTimeOffset? DeprecatedAt,
DateTimeOffset? SunsetAt,
string? ReplacementPath,
string? DocumentationUrl,
int DaysUntilSunset);
/// <summary>
/// Default implementation that logs deprecation notifications.
/// </summary>
public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService
{
private readonly ILogger<LoggingDeprecationNotificationService> _logger;
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
public LoggingDeprecationNotificationService(
ILogger<LoggingDeprecationNotificationService> logger,
IOptionsMonitor<ApiDeprecationOptions> options)
{
_logger = logger;
_options = options;
}
public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default)
{
_logger.LogWarning(
"Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " +
"Replacement: {Replacement}. Message: {Message}",
notification.EndpointPath,
notification.SunsetDate?.ToString("o"),
notification.ReplacementPath ?? "(none)",
notification.Message ?? "(none)");
return Task.CompletedTask;
}
public Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
int withinDays = 90,
CancellationToken cancellationToken = default)
{
var options = _options.CurrentValue;
var now = DateTimeOffset.UtcNow;
var cutoff = now.AddDays(withinDays);
var upcoming = options.DeprecatedEndpoints
.Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now)
.OrderBy(e => e.SunsetAt)
.Select(e => new DeprecationInfo(
e.PathPattern,
e.DeprecatedAt,
e.SunsetAt,
e.ReplacementPath,
e.DeprecationLink,
e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue))
.ToList();
return Task.FromResult<IReadOnlyList<DeprecationInfo>>(upcoming);
}
}

View File

@@ -7,7 +7,7 @@ namespace StellaOps.TaskRunner.WebService;
/// <summary>
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
/// </summary>
internal static class OpenApiMetadataFactory
public static class OpenApiMetadataFactory
{
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
public const string ApiVersion = "0.1.0-draft";
@@ -73,7 +73,7 @@ internal static class OpenApiMetadataFactory
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
/// <param name="ETag">ETag for HTTP caching.</param>
/// <param name="Signature">SHA-256 signature for verification.</param>
internal sealed record OpenApiMetadata(
public sealed record OpenApiMetadata(
string SpecUrl,
string Version,
string BuildVersion,

View File

@@ -5,7 +5,10 @@ using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -17,6 +20,7 @@ using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TaskRunner.WebService;
using StellaOps.TaskRunner.WebService.Deprecation;
using StellaOps.Telemetry.Core;
var builder = WebApplication.CreateBuilder(args);
@@ -95,12 +99,42 @@ builder.Services.AddSingleton(sp =>
});
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
builder.Services.AddApiDeprecation(builder.Configuration);
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
builder.Services.AddOpenApi();
var app = builder.Build();
// Add deprecation middleware for sunset headers (RFC 8594)
app.UseApiDeprecation();
app.MapOpenApi("/openapi");
// Deprecation status endpoint
app.MapGet("/v1/task-runner/deprecations", async (
IDeprecationNotificationService deprecationService,
[FromQuery] int? withinDays,
CancellationToken cancellationToken) =>
{
var days = withinDays ?? 90;
var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new
{
withinDays = days,
deprecations = deprecations.Select(d => new
{
endpoint = d.EndpointPath,
deprecatedAt = d.DeprecatedAt?.ToString("o"),
sunsetAt = d.SunsetAt?.ToString("o"),
daysUntilSunset = d.DaysUntilSunset,
replacement = d.ReplacementPath,
documentation = d.DocumentationUrl
})
});
}).WithName("GetDeprecations").WithTags("API Governance");
app.MapPost("/v1/task-runner/simulations", async (
[FromBody] SimulationRequest request,
TaskPackManifestLoader loader,
@@ -290,11 +324,11 @@ async Task<IResult> HandleStreamRunLogs(
return Results.NotFound();
}
return Results.Stream(async (stream, ct) =>
return Results.Stream(async stream =>
{
await foreach (var entry in logStore.ReadAsync(runId, ct).ConfigureAwait(false))
await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false))
{
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false);
}
}, "application/x-ndjson");
}

View File

@@ -16,11 +16,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
</ItemGroup>