feat(admin): dispatch allowlisted maintenance jobs
CI and Deploy / test (push) Successful in 7m8s
CI and Deploy / deploy (push) Successful in 1m42s

This commit is contained in:
cesnimda
2026-09-01 16:53:54 +02:00
parent 3ec9ac1dad
commit 3b055b01f5
11 changed files with 328 additions and 8 deletions
@@ -18,8 +18,9 @@ public sealed class AdminSystemController : ControllerBase
private readonly ISummarizerService _summarizer;
private readonly IWebHostEnvironment _env;
private readonly IEmailSettingsResolver _emailSettings;
private readonly MaintenanceJobDispatcher _maintenanceJobs;
public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings)
public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings, MaintenanceJobDispatcher maintenanceJobs)
{
_cfg = cfg;
_paths = paths;
@@ -27,6 +28,7 @@ public sealed class AdminSystemController : ControllerBase
_summarizer = summarizer;
_env = env;
_emailSettings = emailSettings;
_maintenanceJobs = maintenanceJobs;
}
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
@@ -35,6 +37,7 @@ public sealed class AdminSystemController : ControllerBase
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
public sealed record MaintenanceJobQueuedDto(string JobId, string Status, string? RunsUrl);
public sealed record SystemStatusDto(
string Environment,
string ContentRoot,
@@ -141,6 +144,27 @@ public sealed class AdminSystemController : ControllerBase
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
}
[HttpGet("maintenance-jobs")]
public ActionResult<IReadOnlyList<MaintenanceJobDefinition>> GetMaintenanceJobs() => Ok(_maintenanceJobs.GetJobs());
[HttpPost("maintenance-jobs/{jobId}/runs")]
public async Task<ActionResult<MaintenanceJobQueuedDto>> RunMaintenanceJob(string jobId, CancellationToken cancellationToken)
{
var job = _maintenanceJobs.GetJobs().SingleOrDefault(candidate => candidate.Id == jobId);
if (job is null) return NotFound("Unknown maintenance job.");
if (!job.Available) return Problem("Maintenance job dispatch is not configured.", statusCode: StatusCodes.Status503ServiceUnavailable);
try
{
await _maintenanceJobs.DispatchAsync(jobId, cancellationToken);
return Accepted(new MaintenanceJobQueuedDto(jobId, "queued", job.RunsUrl));
}
catch (HttpRequestException)
{
return Problem("The maintenance runner did not accept the job. Try again or inspect Gitea Actions.", statusCode: StatusCodes.Status502BadGateway);
}
}
[HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
{
+6
View File
@@ -46,6 +46,7 @@ builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<Curr
builder.Services.AddSingleton<BackgroundTenantRunner>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<UserOperationStore>();
builder.Services.AddSingleton<MaintenanceJobDispatcher>();
builder.Services.AddScoped<EmailSendAttemptStore>();
builder.Services.AddScoped<AccountOwnedFileInventory>();
builder.Services.AddScoped<AccountDataExportService>();
@@ -191,6 +192,11 @@ builder.Services.AddHttpClient("jobimport")
AllowAutoRedirect = false
});
builder.Services.AddHttpClient("maintenance-jobs", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
});
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
// Every caller goes through this named client, so the shared-secret header is set once here.
builder.Services.AddHttpClient("ai-service", client =>
@@ -0,0 +1,76 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace JobTrackerApi.Services;
public sealed record MaintenanceJobDefinition(string Id, string Title, string Description, bool Available, string? RunsUrl);
public sealed class MaintenanceJobDispatcher(IConfiguration configuration, IHttpClientFactory httpClientFactory)
{
public const string CvBenchmarkJobId = "cv-benchmark";
public IReadOnlyList<MaintenanceJobDefinition> GetJobs()
{
var settings = ReadSettings();
return
[
new(
CvBenchmarkJobId,
"CV extraction benchmark",
"Runs the CV corpus benchmark and retains the report and fixture candidates as a workflow artifact.",
settings.IsConfigured,
settings.RunsUrl)
];
}
public async Task DispatchAsync(string jobId, CancellationToken cancellationToken)
{
if (!string.Equals(jobId, CvBenchmarkJobId, StringComparison.Ordinal))
{
throw new KeyNotFoundException("Unknown maintenance job.");
}
var settings = ReadSettings();
if (!settings.IsConfigured)
{
throw new InvalidOperationException("Maintenance job dispatch is not configured.");
}
using var request = new HttpRequestMessage(
HttpMethod.Post,
$"{settings.BaseUrl}/api/v1/repos/{Uri.EscapeDataString(settings.Owner)}/{Uri.EscapeDataString(settings.Repository)}/actions/workflows/maintenance.yml/dispatches");
request.Headers.Authorization = new AuthenticationHeaderValue("token", settings.Token);
request.Content = JsonContent.Create(new
{
@ref = "main",
inputs = new Dictionary<string, string> { ["job"] = jobId }
});
using var response = await httpClientFactory.CreateClient("maintenance-jobs")
.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Gitea rejected the maintenance workflow dispatch with HTTP {(int)response.StatusCode}.",
null,
response.StatusCode);
}
}
private Settings ReadSettings()
{
var baseUrl = (configuration["MaintenanceJobs:GiteaBaseUrl"] ?? "https://git.cesnimda.uk").Trim().TrimEnd('/');
var owner = (configuration["MaintenanceJobs:GiteaOwner"] ?? "cesnimda").Trim();
var repository = (configuration["MaintenanceJobs:GiteaRepository"] ?? "jobtrackingapp").Trim();
var token = (configuration["MaintenanceJobs:GiteaToken"] ?? string.Empty).Trim();
var configured = Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
&& uri.Scheme == Uri.UriSchemeHttps
&& !string.IsNullOrWhiteSpace(owner)
&& !string.IsNullOrWhiteSpace(repository)
&& !string.IsNullOrWhiteSpace(token);
var runsUrl = configured ? $"{baseUrl}/{owner}/{repository}/actions" : null;
return new Settings(baseUrl, owner, repository, token, configured, runsUrl);
}
private sealed record Settings(string BaseUrl, string Owner, string Repository, string Token, bool IsConfigured, string? RunsUrl);
}