feat(admin): dispatch allowlisted maintenance jobs
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user