Redesign security UX: unified Image Security page, VEX hub overhaul, nav simplification
Security nav restructured from 8 items to 4: Image Security, Triage Queue, Risk Overview, Advisory Sources. New Image Security page at /security/images with scope selectors (repo/image/release/environment) and 6 tabs (Summary, Findings, SBOM, Reachability, VEX, Evidence). VEX Hub: removed dashboard tab, moved create to button, fixed filters to use stella-filter-multi, fixed all navigation to absolute paths, fixed 72+ hardcoded rgba colors, created proper page components for conflicts and create workflow. Policy shell: added tabs for Packs, Governance, VEX & Exceptions, Simulation, Audit — all sub-pages now accessible from the Release Policies page. Integrations: moved symbol sources/marketplace and scanner config to /setup/integrations. Backend: mirror config changes now persist via IFeedMirrorConfigStore and propagate to central Scheduler via SchedulerClient. MirrorExportScheduler supports IMirrorSchedulerSignal for immediate wakeup on config change. Mirror detail page: fixed all wrong CSS tokens (text colors used as backgrounds, inverted borders) to canonical Stella Ops design system. Exception dashboard: removed duplicate English/Bulgarian title headers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
@@ -180,9 +182,10 @@ internal static class FeedMirrorManagementEndpoints
|
||||
[FromQuery] string? feedTypes,
|
||||
[FromQuery] string? syncStatuses,
|
||||
[FromQuery] bool? enabled,
|
||||
[FromQuery] string? search)
|
||||
[FromQuery] string? search,
|
||||
[FromServices] IFeedMirrorConfigStore store)
|
||||
{
|
||||
var result = MirrorSeedData.Mirrors.AsEnumerable();
|
||||
var result = store.GetAll().AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedTypes))
|
||||
{
|
||||
@@ -209,24 +212,33 @@ internal static class FeedMirrorManagementEndpoints
|
||||
return HttpResults.Ok(result.ToList());
|
||||
}
|
||||
|
||||
private static IResult GetMirror(string mirrorId)
|
||||
private static IResult GetMirror(string mirrorId, [FromServices] IFeedMirrorConfigStore store)
|
||||
{
|
||||
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
|
||||
var mirror = store.Get(mirrorId);
|
||||
return mirror is not null ? HttpResults.Ok(mirror) : HttpResults.NotFound();
|
||||
}
|
||||
|
||||
private static IResult UpdateMirrorConfig(string mirrorId, [FromBody] MirrorConfigUpdateDto config)
|
||||
private static async Task<IResult> UpdateMirrorConfig(
|
||||
string mirrorId,
|
||||
[FromBody] MirrorConfigUpdateDto config,
|
||||
[FromServices] IFeedMirrorConfigStore store,
|
||||
[FromServices] SchedulerClient scheduler,
|
||||
[FromServices] IMirrorSchedulerSignal signal,
|
||||
HttpContext httpContext)
|
||||
{
|
||||
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
|
||||
if (mirror is null) return HttpResults.NotFound();
|
||||
var updated = store.Update(mirrorId, config);
|
||||
if (updated is null) return HttpResults.NotFound();
|
||||
|
||||
return HttpResults.Ok(mirror with
|
||||
{
|
||||
Enabled = config.Enabled ?? mirror.Enabled,
|
||||
SyncIntervalMinutes = config.SyncIntervalMinutes ?? mirror.SyncIntervalMinutes,
|
||||
UpstreamUrl = config.UpstreamUrl ?? mirror.UpstreamUrl,
|
||||
UpdatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
});
|
||||
// Propagate to central scheduler with deterministic schedule ID
|
||||
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "demo-prod";
|
||||
await scheduler.UpsertMirrorScheduleAsync(
|
||||
tenantId, mirrorId, updated.Name, updated.SyncIntervalMinutes, updated.Enabled,
|
||||
httpContext.RequestAborted);
|
||||
|
||||
// Wake the local export scheduler immediately
|
||||
signal.SignalReconfigured();
|
||||
|
||||
return HttpResults.Ok(updated);
|
||||
}
|
||||
|
||||
private static IResult TriggerSync(string mirrorId)
|
||||
|
||||
@@ -661,7 +661,23 @@ builder.Services.AddHttpClient("MirrorConsumer");
|
||||
|
||||
// Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b)
|
||||
builder.Services.Configure<MirrorDistributionOptions>(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName));
|
||||
builder.Services.AddHostedService<MirrorExportScheduler>();
|
||||
|
||||
// Feed mirror config store (replaces stateless MirrorSeedData for PATCH persistence)
|
||||
builder.Services.AddSingleton<InMemoryFeedMirrorConfigStore>();
|
||||
builder.Services.AddSingleton<IFeedMirrorConfigStore>(sp => sp.GetRequiredService<InMemoryFeedMirrorConfigStore>());
|
||||
|
||||
// Scheduler HTTP client for propagating mirror config to central Scheduler
|
||||
builder.Services.AddHttpClient("Scheduler", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(
|
||||
builder.Configuration["Scheduler:BaseUrl"] ?? "http://scheduler.stella-ops.local");
|
||||
});
|
||||
builder.Services.AddSingleton<SchedulerClient>();
|
||||
|
||||
// MirrorExportScheduler: singleton + signal interface + hosted service
|
||||
builder.Services.AddSingleton<MirrorExportScheduler>();
|
||||
builder.Services.AddSingleton<IMirrorSchedulerSignal>(sp => sp.GetRequiredService<MirrorExportScheduler>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<MirrorExportScheduler>());
|
||||
|
||||
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Concurrent;
|
||||
using static StellaOps.Concelier.WebService.Extensions.FeedMirrorManagementEndpoints;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory store for feed mirror configuration.
|
||||
/// Seeded from <see cref="MirrorSeedData"/> on construction.
|
||||
/// Replaces the stateless seed-data pattern so that PATCH updates are persisted in-process.
|
||||
/// Future: replace with DB-backed store.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryFeedMirrorConfigStore : IFeedMirrorConfigStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FeedMirrorDto> _mirrors;
|
||||
|
||||
public InMemoryFeedMirrorConfigStore()
|
||||
{
|
||||
_mirrors = new ConcurrentDictionary<string, FeedMirrorDto>(
|
||||
MirrorSeedData.Mirrors.ToDictionary(m => m.MirrorId, m => m),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public IReadOnlyList<FeedMirrorDto> GetAll()
|
||||
=> _mirrors.Values.OrderBy(m => m.Name).ToList();
|
||||
|
||||
public FeedMirrorDto? Get(string mirrorId)
|
||||
=> _mirrors.TryGetValue(mirrorId, out var mirror) ? mirror : null;
|
||||
|
||||
public FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update)
|
||||
{
|
||||
if (!_mirrors.TryGetValue(mirrorId, out var existing))
|
||||
return null;
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Enabled = update.Enabled ?? existing.Enabled,
|
||||
SyncIntervalMinutes = update.SyncIntervalMinutes ?? existing.SyncIntervalMinutes,
|
||||
UpstreamUrl = update.UpstreamUrl ?? existing.UpstreamUrl,
|
||||
UpdatedAt = DateTimeOffset.UtcNow.ToString("o"),
|
||||
};
|
||||
|
||||
_mirrors[mirrorId] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for feed mirror config persistence.
|
||||
/// </summary>
|
||||
internal interface IFeedMirrorConfigStore
|
||||
{
|
||||
IReadOnlyList<FeedMirrorDto> GetAll();
|
||||
FeedMirrorDto? Get(string mirrorId);
|
||||
FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the central Scheduler WebService.
|
||||
/// Manages mirror sync schedules using deterministic IDs: sys-{tenantId}-mirror-{mirrorId}.
|
||||
/// </summary>
|
||||
public sealed class SchedulerClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<SchedulerClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public SchedulerClient(IHttpClientFactory httpClientFactory, ILogger<SchedulerClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the scheduler schedule for a feed mirror.
|
||||
/// Uses deterministic schedule ID: sys-{tenantId}-mirror-{mirrorId}.
|
||||
/// </summary>
|
||||
public async Task<bool> UpsertMirrorScheduleAsync(
|
||||
string tenantId,
|
||||
string mirrorId,
|
||||
string mirrorName,
|
||||
int intervalMinutes,
|
||||
bool enabled,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var scheduleId = BuildScheduleId(tenantId, mirrorId);
|
||||
var cron = IntervalToCron(intervalMinutes);
|
||||
var client = _httpClientFactory.CreateClient("Scheduler");
|
||||
|
||||
// Add tenant header required by the scheduler service
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Tenant-Id", tenantId);
|
||||
|
||||
// Try PATCH first (update existing schedule)
|
||||
var patchBody = new
|
||||
{
|
||||
name = $"Mirror Sync: {mirrorName}",
|
||||
cronExpression = cron,
|
||||
timezone = "UTC",
|
||||
enabled,
|
||||
};
|
||||
|
||||
var patchResponse = await client.PatchAsJsonAsync(
|
||||
$"/api/v1/scheduler/schedules/{scheduleId}", patchBody, JsonOptions, ct);
|
||||
|
||||
if (patchResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updated scheduler schedule {ScheduleId} for mirror {MirrorId}: cron={Cron}, enabled={Enabled}",
|
||||
scheduleId, mirrorId, cron, enabled);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (patchResponse.StatusCode != HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to update scheduler schedule {ScheduleId}: {Status}",
|
||||
scheduleId, patchResponse.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Schedule doesn't exist yet — create it
|
||||
var createBody = new
|
||||
{
|
||||
name = $"Mirror Sync: {mirrorName}",
|
||||
cronExpression = cron,
|
||||
timezone = "UTC",
|
||||
enabled,
|
||||
mode = "contentRefresh",
|
||||
selection = new { scope = "allImages", tenantId },
|
||||
source = "system",
|
||||
};
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/scheduler/schedules/", createBody, JsonOptions, ct);
|
||||
|
||||
if (createResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Created scheduler schedule for mirror {MirrorId}: id={ScheduleId}, cron={Cron}",
|
||||
mirrorId, scheduleId, cron);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Failed to create scheduler schedule for mirror {MirrorId}: {Status}",
|
||||
mirrorId, createResponse.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the deterministic schedule ID for a mirror.
|
||||
/// </summary>
|
||||
public static string BuildScheduleId(string tenantId, string mirrorId)
|
||||
=> $"sys-{tenantId}-mirror-{mirrorId}";
|
||||
|
||||
/// <summary>
|
||||
/// Converts an interval in minutes to a cron expression.
|
||||
/// </summary>
|
||||
public static string IntervalToCron(int minutes) => minutes switch
|
||||
{
|
||||
<= 0 => "0 */1 * * *", // fallback: every hour
|
||||
< 60 => $"*/{minutes} * * * *", // every N minutes
|
||||
60 => "0 */1 * * *", // every hour
|
||||
< 1440 => $"0 */{minutes / 60} * * *", // every N hours
|
||||
_ => "0 0 * * *", // daily
|
||||
};
|
||||
}
|
||||
@@ -13,19 +13,32 @@ using StellaOps.Excititor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Signal interface to wake the <see cref="MirrorExportScheduler"/> from its sleep cycle
|
||||
/// when mirror configuration changes at runtime (e.g. sync interval update).
|
||||
/// </summary>
|
||||
public interface IMirrorSchedulerSignal
|
||||
{
|
||||
/// <summary>
|
||||
/// Interrupts the current sleep cycle so the scheduler re-reads its options immediately.
|
||||
/// </summary>
|
||||
void SignalReconfigured();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks configured mirror domains for stale
|
||||
/// export bundles and triggers regeneration when source data has been updated since
|
||||
/// the last bundle generation. Designed for air-gap awareness: can be fully disabled
|
||||
/// via <see cref="MirrorDistributionOptions.AutoRefreshEnabled"/>.
|
||||
/// </summary>
|
||||
public sealed class MirrorExportScheduler : BackgroundService
|
||||
public sealed class MirrorExportScheduler : BackgroundService, IMirrorSchedulerSignal
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<MirrorDistributionOptions> _optionsMonitor;
|
||||
private readonly ILogger<MirrorExportScheduler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, DomainGenerationStatus> _domainStatus = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource _wakeupCts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MirrorExportScheduler"/>.
|
||||
@@ -48,6 +61,15 @@ public sealed class MirrorExportScheduler : BackgroundService
|
||||
public IReadOnlyDictionary<string, DomainGenerationStatus> GetDomainStatuses()
|
||||
=> _domainStatus.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc cref="IMirrorSchedulerSignal.SignalReconfigured"/>
|
||||
public void SignalReconfigured()
|
||||
{
|
||||
var old = Interlocked.Exchange(ref _wakeupCts, new CancellationTokenSource());
|
||||
try { old.Cancel(); } catch (ObjectDisposedException) { }
|
||||
old.Dispose();
|
||||
_logger.LogInformation("MirrorExportScheduler received reconfiguration signal; waking from sleep.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -109,12 +131,18 @@ public sealed class MirrorExportScheduler : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken).ConfigureAwait(false);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _wakeupCts.Token);
|
||||
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Wakeup signal received — loop immediately to re-read options
|
||||
_logger.LogDebug("MirrorExportScheduler woke up from reconfiguration signal.");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("MirrorExportScheduler stopped.");
|
||||
|
||||
Reference in New Issue
Block a user