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:
@@ -14,6 +14,10 @@ public class IntegrityCheckBackgroundService(
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Run once immediately on startup so a freshly (re)started slave has its
|
||||
// master-gate status re-synced right away, instead of waiting up to a full interval.
|
||||
await ExecuteTickAsync(stoppingToken);
|
||||
|
||||
var interval = TimeSpan.FromMinutes(options.Value.IntegrityCheckIntervalMinutes);
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SlpModularCms.Modules.Master.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Master.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Lets a registered slave pull its own configured status from the master, using the
|
||||
/// same shared API key used for master-to-slave calls. This is the counterpart of the
|
||||
/// master-initiated push in CmsInstanceService.UpdateStatusAsync/VerifyIntegrityAsync,
|
||||
/// letting a slave self-heal its master-gate state (e.g. after a restart) instead of
|
||||
/// relying solely on the master successfully reaching it.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("SlaveStatus")]
|
||||
[AllowAnonymous]
|
||||
public class SlaveStatusController(ICmsInstanceService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
|
||||
|
||||
var result = await service.GetStatusForApiKeyAsync(apiKey);
|
||||
if (result is null) return Unauthorized();
|
||||
|
||||
return Ok(new { result.IsAvailable, result.DisableMessage });
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,28 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
||||
deps.Repository.Update(instance);
|
||||
await deps.Repository.SaveChangesAsync();
|
||||
|
||||
if (request.Status == CmsInstanceStatus.Inactive)
|
||||
return new UpdateStatusResult(Success: true, SlaveContactSuccess: true);
|
||||
|
||||
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
|
||||
|
||||
if (request.Status == CmsInstanceStatus.Inactive)
|
||||
{
|
||||
// The master no longer manages this slave, so release the master gate
|
||||
// instead of leaving it stuck on whatever status was last pushed.
|
||||
var released = await deps.SlaveClient.PushStatusAsync(instance.Url, plainKey, isAvailable: true, disableMessage: null);
|
||||
|
||||
if (released)
|
||||
{
|
||||
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
|
||||
deps.Repository.Update(instance);
|
||||
await deps.Repository.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
deps.Logger.LogError("Failed to release master gate on deactivated slave {SlaveUrl}", instance.Url);
|
||||
}
|
||||
|
||||
return new UpdateStatusResult(Success: true, SlaveContactSuccess: released);
|
||||
}
|
||||
|
||||
var pushed = await deps.SlaveClient.PushStatusAsync(
|
||||
instance.Url,
|
||||
plainKey,
|
||||
@@ -94,6 +112,35 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
||||
return new UpdateStatusResult(Success: true, SlaveContactSuccess: pushed);
|
||||
}
|
||||
|
||||
public async Task<SlaveStatusPollResponse?> GetStatusForApiKeyAsync(string plainApiKey)
|
||||
{
|
||||
var instances = await deps.Repository.GetActiveAsync();
|
||||
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
string? candidateKey;
|
||||
try
|
||||
{
|
||||
candidateKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidateKey != plainApiKey)
|
||||
continue;
|
||||
|
||||
instance.LastContactedAt = DateTimeOffset.UtcNow;
|
||||
deps.Repository.Update(instance);
|
||||
await deps.Repository.SaveChangesAsync();
|
||||
|
||||
return new SlaveStatusPollResponse(instance.Status == CmsInstanceStatus.Available, instance.DisableMessage);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task VerifyIntegrityAsync()
|
||||
{
|
||||
var masterUrl = ResolveMasterUrl();
|
||||
@@ -140,6 +187,20 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
|
||||
instance.LastIntegrityCheckFailedAt = null;
|
||||
}
|
||||
|
||||
// The slave keeps its master-gate status in memory only, so a slave restart
|
||||
// silently resets it to "available" until the next explicit status change.
|
||||
// Re-push the master's persisted status every check to keep it in sync.
|
||||
var pushed = await deps.SlaveClient.PushStatusAsync(
|
||||
instance.Url,
|
||||
plainKey,
|
||||
isAvailable: instance.Status == CmsInstanceStatus.Available,
|
||||
disableMessage: instance.DisableMessage);
|
||||
|
||||
if (pushed)
|
||||
{
|
||||
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
deps.Repository.Update(instance);
|
||||
await deps.Repository.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -8,4 +8,12 @@ public interface ICmsInstanceService
|
||||
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
|
||||
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
|
||||
Task VerifyIntegrityAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the registered CMS instance by its plain (unprotected) API key, for a slave
|
||||
/// pulling its own status. Returns null when no instance's key matches (unauthorized).
|
||||
/// </summary>
|
||||
Task<SlaveStatusPollResponse?> GetStatusForApiKeyAsync(string plainApiKey);
|
||||
}
|
||||
|
||||
public record SlaveStatusPollResponse(bool IsAvailable, string? DisableMessage);
|
||||
|
||||
Reference in New Issue
Block a user