Completes local-dev-master-slave-setup: dual-instance frontend tooling, module-capability gating, and master/slave protocol self-healing fixes
Frontend (Unit 2 completion): dual dev-server tooling (pnpm dev:slave, pnpm dev:all), per-instance browser tab titles, and a backend capability check (SystemController + useSystemCapabilities + ModuleGuard) so a Master-only page is hidden on a slave instance instead of assuming every backend has every module. Master/slave protocol fixes surfaced by actually running master and slave side by side locally: - Deactivating a CMS instance (Inactive) now releases the slave's master gate instead of leaving it stuck on its last pushed status. - The periodic integrity check now also re-pushes status to every reachable slave (previously URL-verification only) and runs once immediately on startup. - Added the originally-specified (but never implemented) slave-pull path: a slave now periodically polls its own status from the master (GET /api/v1/SlaveStatus) and fails open to Available if the master is unreachable for too long, complementing the existing push. - The slave's own Settings page can no longer "successfully" change local availability while the master controls it; it's now locked with an explanatory banner and the backend rejects the write with 409 instead of silently no-op'ing it. - CMS instance status badges now match the dashboard's color/icon styling instead of a plain grey badge. Also corrected the master-cms-module design docs to match this as-built behavior, and flagged (without a full rewrite) a larger, pre-existing divergence between its inception-stage application design and what construction actually built. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Modules;
|
||||
using SlpModularCms.Modules.Availability.BackgroundServices;
|
||||
using SlpModularCms.Modules.Availability.Config;
|
||||
using SlpModularCms.Modules.Availability.Data;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Repositories;
|
||||
@@ -34,6 +36,14 @@ public class AvailabilityModule : IModule
|
||||
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
|
||||
|
||||
services.AddOptions<MasterPollingOptions>().BindConfiguration("MasterPolling");
|
||||
services.AddHttpClient<IMasterStatusPollClient, MasterStatusPollClient>((sp, client) =>
|
||||
{
|
||||
var pollingOptions = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<MasterPollingOptions>>();
|
||||
client.Timeout = TimeSpan.FromSeconds(pollingOptions.Value.HttpTimeoutSeconds);
|
||||
});
|
||||
services.AddHostedService<MasterStatusPollingBackgroundService>();
|
||||
}
|
||||
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Modules.Availability.Config;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically pulls this slave's status from its registered master, so the slave's
|
||||
/// in-memory master-gate stays correct even without an explicit push from the master
|
||||
/// (e.g. after a slave restart, or if a push was missed). Fails open (Available) if the
|
||||
/// master has been unreachable for too long.
|
||||
/// </summary>
|
||||
public class MasterStatusPollingBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<MasterPollingOptions> options,
|
||||
ILogger<MasterStatusPollingBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Poll once immediately on startup, so a freshly (re)started slave re-syncs right away
|
||||
// instead of defaulting to Available until the first interval elapses.
|
||||
await ExecuteTickAsync(stoppingToken);
|
||||
|
||||
var interval = TimeSpan.FromSeconds(options.Value.PollIntervalSeconds);
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await ExecuteTickAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task ExecuteTickAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var masterAvailabilityService = scope.ServiceProvider.GetRequiredService<IMasterAvailabilityService>();
|
||||
var pollClient = scope.ServiceProvider.GetRequiredService<IMasterStatusPollClient>();
|
||||
|
||||
var target = await masterAvailabilityService.GetPollTargetAsync();
|
||||
if (target is null)
|
||||
{
|
||||
// No master registered yet; nothing to poll.
|
||||
return;
|
||||
}
|
||||
|
||||
var polled = await pollClient.GetStatusAsync(target.MasterUrl, target.PlainApiKey);
|
||||
if (polled is not null)
|
||||
{
|
||||
await masterAvailabilityService.ApplyPolledStatusAsync(polled.IsAvailable, polled.DisableMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
var failOpenAfter = TimeSpan.FromMinutes(options.Value.FailOpenAfterMinutes);
|
||||
await masterAvailabilityService.RecordPollFailureAsync(failOpenAfter);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unhandled error during master status poll tick.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Config;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public class MasterPollingOptions
|
||||
{
|
||||
/// <summary>How often the slave pulls its status from the master.</summary>
|
||||
public int PollIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How long the master may stay unreachable before the slave gives up waiting and
|
||||
/// fails open (becomes Available again) instead of staying stuck on a stale status.
|
||||
/// </summary>
|
||||
public int FailOpenAfterMinutes { get; set; } = 5;
|
||||
|
||||
public int HttpTimeoutSeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
@@ -26,6 +27,7 @@ public class AvailabilityController : ControllerBase
|
||||
Status = details.Status.ToString(),
|
||||
CheckedAt = DateTimeOffset.UtcNow,
|
||||
Message = details.Message ?? string.Empty,
|
||||
details.IsMasterControlled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,17 +35,28 @@ public class AvailabilityController : ControllerBase
|
||||
[Authorize(Policy = "OwnerOnly")]
|
||||
public async Task<IActionResult> UpdateStatus([FromBody] UpdateStatusRequest request)
|
||||
{
|
||||
if (_availabilityService is PersistentAvailabilityService persistentService)
|
||||
if (_availabilityService is not PersistentAvailabilityService persistentService)
|
||||
{
|
||||
await persistentService.UpdateStatusAsync(
|
||||
request.NewStatus,
|
||||
request.Reason,
|
||||
User.Identity?.Name);
|
||||
|
||||
return Ok();
|
||||
return BadRequest("Status update niet ondersteund door huidige service.");
|
||||
}
|
||||
|
||||
return BadRequest("Status update niet ondersteund door huidige service.");
|
||||
try
|
||||
{
|
||||
await persistentService.UpdateStatusAsync(
|
||||
request.NewStatus,
|
||||
request.Reason,
|
||||
User.Identity?.Name);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (MasterControlledAvailabilityException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status409Conflict,
|
||||
Title = ex.Message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,7 @@ public class MasterRegistration
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public DateTimeOffset RegisteredAt { get; set; }
|
||||
public DateTimeOffset? LastContactedAt { get; set; }
|
||||
|
||||
/// <summary>Timestamp of the last time this slave successfully polled the master for its status.</summary>
|
||||
public DateTimeOffset? LastPolledAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,12 +23,15 @@ public class AvailabilityMiddleware
|
||||
// Auth and Setup must stay open so admins can log in and the frontend
|
||||
// can determine whether the system is initialized.
|
||||
// Master endpoints bypass so master can always push status or re-register.
|
||||
// SlaveStatus bypasses so a slave can always pull the master's status, even if the
|
||||
// master instance is (for whatever reason) reporting itself as locally unavailable.
|
||||
private static readonly string[] _bypassPrefixes =
|
||||
[
|
||||
"/api/v1/Availability/status",
|
||||
"/api/v1/Auth/",
|
||||
"/api/v1/Setup/status",
|
||||
"/api/v1/master/",
|
||||
"/api/v1/SlaveStatus",
|
||||
];
|
||||
|
||||
public async Task InvokeAsync(
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SlpModularCms.Modules.Availability.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Migrations
|
||||
{
|
||||
[DbContext(typeof(AvailabilityDbContext))]
|
||||
[Migration("20260704142458_AddLastPolledAtToMasterRegistration")]
|
||||
partial class AddLastPolledAtToMasterRegistration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Modules.Availability.Data.Entities.MasterRegistration", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastContactedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastPolledAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("MasterUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("RegisteredAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AvailabilityMasterRegistrations", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastPolledAtToMasterRegistration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "LastPolledAt",
|
||||
table: "AvailabilityMasterRegistrations",
|
||||
type: "datetimeoffset",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastPolledAt",
|
||||
table: "AvailabilityMasterRegistrations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -36,6 +36,9 @@ namespace SlpModularCms.Modules.Availability.Migrations
|
||||
b.Property<DateTimeOffset?>("LastContactedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastPolledAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("MasterUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
|
||||
@@ -6,4 +6,20 @@ public interface IMasterAvailabilityService
|
||||
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
|
||||
Task<string?> GetRegisteredUrlAsync(string apiKey);
|
||||
MasterGateStatus GetMasterStatus();
|
||||
|
||||
/// <summary>Returns the master URL and plain API key to poll, or null if no master is registered.</summary>
|
||||
Task<MasterPollTarget?> GetPollTargetAsync();
|
||||
|
||||
/// <summary>Applies a successfully polled status from the master and records the poll timestamp.</summary>
|
||||
Task ApplyPolledStatusAsync(bool isAvailable, string? disableMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Called after a failed poll attempt. If the master has been unreachable for longer than
|
||||
/// <paramref name="failOpenAfter"/>, forces the gate open (Available) so a dead/unreachable
|
||||
/// master never permanently blocks the slave.
|
||||
/// </summary>
|
||||
Task RecordPollFailureAsync(TimeSpan failOpenAfter);
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public record MasterPollTarget(string MasterUrl, string PlainApiKey);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public interface IMasterStatusPollClient
|
||||
{
|
||||
Task<PolledMasterStatus?> GetStatusAsync(string masterUrl, string plainApiKey);
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
|
||||
public record PolledMasterStatus(bool IsAvailable, string? DisableMessage);
|
||||
@@ -72,6 +72,51 @@ public class MasterAvailabilityService : IMasterAvailabilityService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<MasterPollTarget?> GetPollTargetAsync()
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
if (existing is null) return null;
|
||||
|
||||
var plainKey = _deps.KeyProtector.Unprotect(existing.ApiKey);
|
||||
if (plainKey is null) return null;
|
||||
|
||||
return new MasterPollTarget(existing.MasterUrl, plainKey);
|
||||
}
|
||||
|
||||
public async Task ApplyPolledStatusAsync(bool isAvailable, string? disableMessage)
|
||||
{
|
||||
_masterIsAvailable = isAvailable;
|
||||
_masterDisableMessage = disableMessage;
|
||||
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
if (existing is null) return;
|
||||
|
||||
existing.LastPolledAt = DateTimeOffset.UtcNow;
|
||||
existing.LastContactedAt = DateTimeOffset.UtcNow;
|
||||
_deps.Repository.Update(existing);
|
||||
await _deps.Repository.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RecordPollFailureAsync(TimeSpan failOpenAfter)
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
if (existing is null) return;
|
||||
|
||||
var unreachableSince = existing.LastPolledAt ?? existing.RegisteredAt;
|
||||
if (DateTimeOffset.UtcNow - unreachableSince < failOpenAfter)
|
||||
return;
|
||||
|
||||
if (_masterIsAvailable)
|
||||
return;
|
||||
|
||||
_deps.Logger.LogWarning(
|
||||
"Master unreachable since {UnreachableSince}; failing open (Available) after {FailOpenAfter}.",
|
||||
unreachableSince, failOpenAfter);
|
||||
|
||||
_masterIsAvailable = true;
|
||||
_masterDisableMessage = null;
|
||||
}
|
||||
|
||||
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
|
||||
{
|
||||
var existing = await _deps.Repository.GetAsync();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
public class MasterStatusPollClient(HttpClient httpClient) : IMasterStatusPollClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<PolledMasterStatus?> GetStatusAsync(string masterUrl, string plainApiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{masterUrl.TrimEnd('/')}/api/v1/SlaveStatus");
|
||||
request.Headers.Add("X-Master-Api-Key", plainApiKey);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SlaveStatusResponse>(JsonOptions);
|
||||
return result is null ? null : new PolledMasterStatus(result.IsAvailable, result.DisableMessage);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record SlaveStatusResponse(bool IsAvailable, string? DisableMessage);
|
||||
}
|
||||
@@ -13,15 +13,20 @@ public class PersistentAvailabilityService : IAvailabilityService
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly AvailabilityOptions _options;
|
||||
|
||||
private readonly IMasterAvailabilityService _masterAvailabilityService;
|
||||
|
||||
// Circuit Breaker state
|
||||
private static DateTimeOffset _lastErrorTime = DateTimeOffset.MinValue;
|
||||
private static AvailabilityStatus _cachedStatus = AvailabilityStatus.Available;
|
||||
|
||||
public PersistentAvailabilityService(ApplicationDbContext context, IOptions<AvailabilityOptions> options)
|
||||
public PersistentAvailabilityService(
|
||||
ApplicationDbContext context,
|
||||
IOptions<AvailabilityOptions> options,
|
||||
IMasterAvailabilityService masterAvailabilityService)
|
||||
{
|
||||
_context = context;
|
||||
_options = options.Value;
|
||||
_masterAvailabilityService = masterAvailabilityService;
|
||||
}
|
||||
|
||||
public async Task<AvailabilityStatus> IsAvailableAsync()
|
||||
@@ -56,6 +61,12 @@ public class PersistentAvailabilityService : IAvailabilityService
|
||||
|
||||
public async Task<AvailabilityStatusDetails> GetStatusDetailsAsync()
|
||||
{
|
||||
var masterStatus = _masterAvailabilityService.GetMasterStatus();
|
||||
if (!masterStatus.IsAvailable)
|
||||
{
|
||||
return new AvailabilityStatusDetails(AvailabilityStatus.NotAvailable, masterStatus.DisableMessage, IsMasterControlled: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||
@@ -77,6 +88,12 @@ public class PersistentAvailabilityService : IAvailabilityService
|
||||
/// </summary>
|
||||
public async Task UpdateStatusAsync(AvailabilityStatus newStatus, string? reason, string? updatedBy)
|
||||
{
|
||||
var masterStatus = _masterAvailabilityService.GetMasterStatus();
|
||||
if (!masterStatus.IsAvailable)
|
||||
{
|
||||
throw new MasterControlledAvailabilityException(masterStatus.DisableMessage);
|
||||
}
|
||||
|
||||
var state = await _context.AvailabilityStates.FirstOrDefaultAsync();
|
||||
|
||||
if (state == null)
|
||||
|
||||
Reference in New Issue
Block a user