save progress
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.FailureSignatures;
|
||||
|
||||
internal static class FailureSignatureEndpoints
|
||||
{
|
||||
private const string ReadScope = "scheduler.runs.read";
|
||||
|
||||
public static IEndpointRouteBuilder MapFailureSignatureEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/scheduler/failure-signatures");
|
||||
|
||||
group.MapGet("/best-match", GetBestMatchAsync);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBestMatchAsync(
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? scopeType,
|
||||
[FromQuery] string? scopeId,
|
||||
[FromQuery] string? toolchainHash,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
[FromServices] IScopeAuthorizer scopeAuthorizer,
|
||||
[FromServices] IServiceProvider serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeType))
|
||||
{
|
||||
throw new ValidationException("scopeType must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopeId))
|
||||
{
|
||||
throw new ValidationException("scopeId must be provided.");
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<FailureSignatureScopeType>(scopeType.Trim(), ignoreCase: true, out var parsedScopeType))
|
||||
{
|
||||
throw new ValidationException($"scopeType '{scopeType}' is not valid.");
|
||||
}
|
||||
|
||||
var repository = serviceProvider.GetService<IFailureSignatureRepository>();
|
||||
if (repository is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Failure signature storage is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var match = await repository
|
||||
.GetBestMatchAsync(
|
||||
tenant.TenantId,
|
||||
parsedScopeType,
|
||||
scopeId.Trim(),
|
||||
string.IsNullOrWhiteSpace(toolchainHash) ? null : toolchainHash.Trim(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
return Results.Ok(new FailureSignatureBestMatchResponse(match));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FailureSignatureBestMatchResponse
|
||||
{
|
||||
public FailureSignatureBestMatchResponse(FailureSignatureEntity signature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
|
||||
SignatureId = signature.SignatureId;
|
||||
ScopeType = signature.ScopeType.ToString().ToLowerInvariant();
|
||||
ScopeId = signature.ScopeId;
|
||||
ToolchainHash = signature.ToolchainHash;
|
||||
ErrorCode = signature.ErrorCode;
|
||||
ErrorCategory = signature.ErrorCategory?.ToString().ToLowerInvariant();
|
||||
PredictedOutcome = signature.PredictedOutcome.ToString().ToLowerInvariant();
|
||||
ConfidenceScore = signature.ConfidenceScore;
|
||||
OccurrenceCount = signature.OccurrenceCount;
|
||||
FirstSeenAt = signature.FirstSeenAt;
|
||||
LastSeenAt = signature.LastSeenAt;
|
||||
}
|
||||
|
||||
public Guid SignatureId { get; }
|
||||
public string ScopeType { get; }
|
||||
public string ScopeId { get; }
|
||||
public string ToolchainHash { get; }
|
||||
public string? ErrorCode { get; }
|
||||
public string? ErrorCategory { get; }
|
||||
public string PredictedOutcome { get; }
|
||||
public decimal? ConfidenceScore { get; }
|
||||
public int OccurrenceCount { get; }
|
||||
public DateTimeOffset FirstSeenAt { get; }
|
||||
public DateTimeOffset LastSeenAt { get; }
|
||||
}
|
||||
}
|
||||
@@ -245,8 +245,8 @@ internal static class PolicySimulationEndpointExtensions
|
||||
|
||||
var preview = new
|
||||
{
|
||||
candidates = inputs.Targets?.Count ?? 0,
|
||||
estimatedRuns = inputs.Targets?.Count ?? 0,
|
||||
candidates = inputs.SbomSet.Length,
|
||||
estimatedRuns = inputs.SbomSet.Length,
|
||||
message = "preview pending execution; actual diff will be available once job starts"
|
||||
};
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.FailureSignatures;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
@@ -213,6 +215,7 @@ app.MapGraphJobEndpoints();
|
||||
ResolverJobEndpointExtensions.MapResolverJobEndpoints(app);
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapFailureSignatureEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapPolicySimulationEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
@@ -94,6 +94,17 @@ internal sealed class InMemoryRunRepository : IRunRepository
|
||||
query = query.Where(run => run.CreatedAt > createdAfter);
|
||||
}
|
||||
|
||||
if (options.Cursor is { } cursor)
|
||||
{
|
||||
query = options.SortAscending
|
||||
? query.Where(run => run.CreatedAt > cursor.CreatedAt ||
|
||||
(run.CreatedAt == cursor.CreatedAt &&
|
||||
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) > 0))
|
||||
: query.Where(run => run.CreatedAt < cursor.CreatedAt ||
|
||||
(run.CreatedAt == cursor.CreatedAt &&
|
||||
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) < 0));
|
||||
}
|
||||
|
||||
query = options.SortAscending
|
||||
? query.OrderBy(run => run.CreatedAt).ThenBy(run => run.Id, StringComparer.Ordinal)
|
||||
: query.OrderByDescending(run => run.CreatedAt).ThenByDescending(run => run.Id, StringComparer.Ordinal);
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Runs;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Schedules;
|
||||
|
||||
public interface ISchedulerAuditService
|
||||
{
|
||||
Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record SchedulerAuditEvent(
|
||||
string TenantId,
|
||||
string Category,
|
||||
string Action,
|
||||
AuditActor Actor,
|
||||
DateTimeOffset? OccurredAt = null,
|
||||
string? AuditId = null,
|
||||
string? EntityId = null,
|
||||
string? ScheduleId = null,
|
||||
string? RunId = null,
|
||||
string? CorrelationId = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
string? Message = null);
|
||||
6
src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md
Normal file
6
src/Scheduler/StellaOps.Scheduler.WebService/TASKS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Active Tasks
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Notes |
|
||||
|----|--------|----------|------------|-------------|-------|
|
||||
| SCHED-WS-TTFS-0341-T4 | DONE (2025-12-18) | Agent | `docs/implplan/SPRINT_0341_0001_0001_ttfs_enhancements.md` | Add failure signature best-match endpoint to support TTFS FirstSignal enrichment. | `GET /api/v1/scheduler/failure-signatures/best-match` + deterministic endpoint tests. |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Scheduler.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed cursor used for deterministic pagination of scheduler runs.
|
||||
/// </summary>
|
||||
public readonly record struct RunListCursor
|
||||
{
|
||||
public RunListCursor(DateTimeOffset createdAt, string runId)
|
||||
{
|
||||
CreatedAt = Validation.NormalizeTimestamp(createdAt);
|
||||
RunId = Validation.EnsureId(runId, nameof(runId));
|
||||
}
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string RunId { get; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
-- Scheduler graph jobs schema (Postgres)
|
||||
|
||||
-- Legacy compatibility:
|
||||
-- Earlier schema revisions shipped `scheduler.graph_jobs` as a TEXT/column-based table in `001_initial_schema.sql`.
|
||||
-- This migration introduces a new JSON-payload based model with a `type` column and will fail on fresh installs
|
||||
-- unless we either migrate or rename the legacy table first.
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'scheduler'
|
||||
AND table_name = 'graph_jobs'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'scheduler'
|
||||
AND table_name = 'graph_jobs'
|
||||
AND column_name = 'type'
|
||||
) THEN
|
||||
-- Rename legacy table so we can create the v2 shape under the canonical name.
|
||||
ALTER TABLE scheduler.graph_jobs RENAME TO graph_jobs_legacy;
|
||||
|
||||
-- Rename legacy constraints to avoid name collisions with the new table (e.g. graph_jobs_pkey).
|
||||
FOR rec IN
|
||||
SELECT c.conname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class rel ON rel.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = rel.relnamespace
|
||||
WHERE n.nspname = 'scheduler'
|
||||
AND rel.relname = 'graph_jobs_legacy'
|
||||
LOOP
|
||||
IF rec.conname LIKE 'graph_jobs%' THEN
|
||||
EXECUTE format(
|
||||
'ALTER TABLE scheduler.graph_jobs_legacy RENAME CONSTRAINT %I TO %I',
|
||||
rec.conname,
|
||||
replace(rec.conname, 'graph_jobs', 'graph_jobs_legacy'));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- Rename legacy indexes to avoid collisions (idx_graph_jobs_* and graph_jobs_pkey).
|
||||
FOR rec IN
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'scheduler'
|
||||
AND tablename = 'graph_jobs_legacy'
|
||||
LOOP
|
||||
IF rec.indexname = 'graph_jobs_pkey' THEN
|
||||
EXECUTE format(
|
||||
'ALTER INDEX scheduler.%I RENAME TO %I',
|
||||
rec.indexname,
|
||||
'graph_jobs_legacy_pkey');
|
||||
ELSIF rec.indexname LIKE 'idx_graph_jobs%' THEN
|
||||
EXECUTE format(
|
||||
'ALTER INDEX scheduler.%I RENAME TO %I',
|
||||
rec.indexname,
|
||||
replace(rec.indexname, 'idx_graph_jobs', 'idx_graph_jobs_legacy'));
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scheduler.graph_job_type AS ENUM ('build', 'overlay');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class DistributedLockRepository : RepositoryBase<SchedulerDataSour
|
||||
holder_id = EXCLUDED.holder_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
acquired_at = NOW(),
|
||||
expires_at = NOW() + EXCLUDED.expires_at - EXCLUDED.acquired_at
|
||||
expires_at = NOW() + @duration
|
||||
WHERE scheduler.locks.expires_at < NOW()
|
||||
""";
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class RunQueryOptions
|
||||
public string? ScheduleId { get; init; }
|
||||
public ImmutableArray<RunState> States { get; init; } = ImmutableArray<RunState>.Empty;
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
public RunListCursor? Cursor { get; init; }
|
||||
public bool SortAscending { get; init; } = false;
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ LIMIT 1;
|
||||
filters.Add("created_at > @CreatedAfter");
|
||||
}
|
||||
|
||||
if (options.Cursor is { } cursor)
|
||||
{
|
||||
filters.Add(options.SortAscending
|
||||
? "(created_at, id) > (@CursorCreatedAt, @CursorId)"
|
||||
: "(created_at, id) < (@CursorCreatedAt, @CursorId)");
|
||||
}
|
||||
|
||||
var order = options.SortAscending ? "created_at ASC, id ASC" : "created_at DESC, id DESC";
|
||||
var limit = options.Limit.GetValueOrDefault(50);
|
||||
|
||||
@@ -117,6 +124,8 @@ LIMIT @Limit;
|
||||
ScheduleId = options.ScheduleId,
|
||||
States = options.States.Select(s => s.ToString().ToLowerInvariant()).ToArray(),
|
||||
CreatedAfter = options.CreatedAfter?.UtcDateTime,
|
||||
CursorCreatedAt = options.Cursor?.CreatedAt.UtcDateTime,
|
||||
CursorId = options.Cursor?.RunId,
|
||||
Limit = limit
|
||||
});
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
public sealed class ScheduleQueryOptions
|
||||
{
|
||||
public bool IncludeDisabled { get; init; } = false;
|
||||
public bool IncludeDeleted { get; init; } = false;
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
|
||||
@@ -93,9 +93,14 @@ LIMIT 1;
|
||||
options ??= new ScheduleQueryOptions();
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
|
||||
|
||||
var where = options.IncludeDisabled
|
||||
? "tenant_id = @TenantId AND deleted_at IS NULL"
|
||||
: "tenant_id = @TenantId AND deleted_at IS NULL AND enabled = TRUE";
|
||||
var where = options.IncludeDeleted
|
||||
? "tenant_id = @TenantId"
|
||||
: "tenant_id = @TenantId AND deleted_at IS NULL";
|
||||
|
||||
if (!options.IncludeDisabled)
|
||||
{
|
||||
where += " AND enabled = TRUE";
|
||||
}
|
||||
|
||||
var limit = options.Limit.GetValueOrDefault(200);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IScheduleRepository, ScheduleRepository>();
|
||||
services.AddScoped<IImpactSnapshotRepository, ImpactSnapshotRepository>();
|
||||
services.AddScoped<IPolicyRunJobRepository, PolicyRunJobRepository>();
|
||||
services.AddScoped<IFailureSignatureRepository, FailureSignatureRepository>();
|
||||
services.AddSingleton<IRunSummaryService, RunSummaryService>();
|
||||
|
||||
return services;
|
||||
@@ -65,6 +66,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IJobHistoryRepository, JobHistoryRepository>();
|
||||
services.AddScoped<IMetricsRepository, MetricsRepository>();
|
||||
services.AddScoped<IGraphJobRepository, GraphJobRepository>();
|
||||
services.AddScoped<IFailureSignatureRepository, FailureSignatureRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
@@ -34,18 +36,6 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
private static GraphOverlayJob OverlayJob(string tenant, string id, GraphJobStatus status = GraphJobStatus.Pending)
|
||||
=> new(
|
||||
id: id,
|
||||
tenantId: tenant,
|
||||
graphSnapshotId: "snap-1",
|
||||
status: status,
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
attempts: 0,
|
||||
targetGraphId: "graph-1",
|
||||
correlationId: null,
|
||||
metadata: null);
|
||||
|
||||
[Fact]
|
||||
public async Task InsertAndGetBuildJob()
|
||||
{
|
||||
@@ -117,7 +107,7 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
|
||||
private SchedulerDataSource CreateDataSource()
|
||||
{
|
||||
var options = _fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = _fixture.SchemaName;
|
||||
return new SchedulerDataSource(Options.Create(options));
|
||||
options.SchemaName = SchedulerDataSource.DefaultSchemaName;
|
||||
return new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using Xunit;
|
||||
@@ -15,6 +16,44 @@ public sealed class SchedulerPostgresFixture : PostgresIntegrationFixture, IColl
|
||||
=> typeof(SchedulerDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Scheduler";
|
||||
|
||||
public new async Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Base fixture truncates the randomly-generated test schema (e.g. schema_migrations table lives there).
|
||||
await Fixture.TruncateAllTablesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scheduler migrations create the canonical `scheduler.*` schema explicitly, so we must truncate it as well
|
||||
// to ensure test isolation between methods.
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string listTablesSql = """
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'scheduler'
|
||||
AND table_type = 'BASE TABLE';
|
||||
""";
|
||||
|
||||
var tables = new List<string>();
|
||||
await using (var command = new NpgsqlCommand(listTablesSql, connection))
|
||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var qualified = tables.Select(static t => $"scheduler.\"{t}\"");
|
||||
var truncateSql = $"TRUNCATE TABLE {string.Join(", ", qualified)} RESTART IDENTITY CASCADE;";
|
||||
await using var truncateCommand = new NpgsqlCommand(truncateSql, connection);
|
||||
await truncateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -12,17 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Update="xunit" Version="2.9.2" />
|
||||
<PackageReference Update="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Update="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Tests;
|
||||
|
||||
public sealed class FailureSignatureEndpointTests : IClassFixture<SchedulerWebApplicationFactory>
|
||||
{
|
||||
private readonly SchedulerWebApplicationFactory _factory;
|
||||
|
||||
public FailureSignatureEndpointTests(SchedulerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BestMatch_WhenMissing_ReturnsNoContent()
|
||||
{
|
||||
var repository = new StubFailureSignatureRepository(match: null);
|
||||
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IFailureSignatureRepository>();
|
||||
services.AddSingleton<IFailureSignatureRepository>(repository);
|
||||
}));
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.NotNull(repository.LastCall);
|
||||
Assert.Equal(FailureSignatureScopeType.Repo, repository.LastCall!.Value.ScopeType);
|
||||
Assert.Equal("acme/repo", repository.LastCall!.Value.ScopeId);
|
||||
Assert.Equal("tch_123", repository.LastCall!.Value.ToolchainHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BestMatch_WhenPresent_ReturnsPayload()
|
||||
{
|
||||
var signatureId = Guid.Parse("e22132b0-2aa7-4cde-94a9-0b335d321c61");
|
||||
var firstSeen = new DateTimeOffset(2025, 12, 18, 10, 0, 0, TimeSpan.Zero);
|
||||
var lastSeen = firstSeen.AddMinutes(5);
|
||||
|
||||
var signature = new FailureSignatureEntity
|
||||
{
|
||||
SignatureId = signatureId,
|
||||
TenantId = "tenant-failure-signatures",
|
||||
ScopeType = FailureSignatureScopeType.Repo,
|
||||
ScopeId = "acme/repo",
|
||||
ToolchainHash = "tch_123",
|
||||
ErrorCode = "E123",
|
||||
ErrorCategory = ErrorCategory.Network,
|
||||
OccurrenceCount = 7,
|
||||
FirstSeenAt = firstSeen,
|
||||
LastSeenAt = lastSeen,
|
||||
PredictedOutcome = PredictedOutcome.Fail,
|
||||
ConfidenceScore = 0.85m
|
||||
};
|
||||
|
||||
var repository = new StubFailureSignatureRepository(signature);
|
||||
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IFailureSignatureRepository>();
|
||||
services.AddSingleton<IFailureSignatureRepository>(repository);
|
||||
}));
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
|
||||
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(signatureId, payload.GetProperty("signatureId").GetGuid());
|
||||
Assert.Equal("repo", payload.GetProperty("scopeType").GetString());
|
||||
Assert.Equal("acme/repo", payload.GetProperty("scopeId").GetString());
|
||||
Assert.Equal("tch_123", payload.GetProperty("toolchainHash").GetString());
|
||||
Assert.Equal("E123", payload.GetProperty("errorCode").GetString());
|
||||
Assert.Equal("network", payload.GetProperty("errorCategory").GetString());
|
||||
Assert.Equal("fail", payload.GetProperty("predictedOutcome").GetString());
|
||||
Assert.Equal(7, payload.GetProperty("occurrenceCount").GetInt32());
|
||||
Assert.Equal(0.85m, payload.GetProperty("confidenceScore").GetDecimal());
|
||||
Assert.Equal(firstSeen, payload.GetProperty("firstSeenAt").GetDateTimeOffset());
|
||||
Assert.Equal(lastSeen, payload.GetProperty("lastSeenAt").GetDateTimeOffset());
|
||||
}
|
||||
|
||||
private sealed class StubFailureSignatureRepository : IFailureSignatureRepository
|
||||
{
|
||||
private readonly FailureSignatureEntity? _match;
|
||||
|
||||
public StubFailureSignatureRepository(FailureSignatureEntity? match)
|
||||
{
|
||||
_match = match;
|
||||
}
|
||||
|
||||
public (string TenantId, FailureSignatureScopeType ScopeType, string ScopeId, string? ToolchainHash)? LastCall { get; private set; }
|
||||
|
||||
public Task<FailureSignatureEntity> CreateAsync(FailureSignatureEntity signature, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<FailureSignatureEntity?> GetByIdAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<FailureSignatureEntity?> GetByKeyAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<FailureSignatureEntity>> GetByScopeAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<FailureSignatureEntity>> GetUnresolvedAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<IReadOnlyList<FailureSignatureEntity>> GetByPredictedOutcomeAsync(string tenantId, PredictedOutcome outcome, decimal minConfidence = 0.5m, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<FailureSignatureEntity> UpsertOccurrenceAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, ErrorCategory? errorCategory, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<bool> UpdateResolutionAsync(string tenantId, Guid signatureId, ResolutionStatus status, string? notes, string? resolvedBy, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<bool> UpdatePredictionAsync(string tenantId, Guid signatureId, PredictedOutcome outcome, decimal confidence, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<int> PruneResolvedAsync(string tenantId, TimeSpan olderThan, CancellationToken cancellationToken = default) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public Task<FailureSignatureEntity?> GetBestMatchAsync(
|
||||
string tenantId,
|
||||
FailureSignatureScopeType scopeType,
|
||||
string scopeId,
|
||||
string? toolchainHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastCall = (tenantId, scopeType, scopeId, toolchainHash);
|
||||
return Task.FromResult(_match);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user