Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.BackgroundServices;
public class IntegrityCheckBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<MasterModuleOptions> options,
ILogger<IntegrityCheckBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(options.Value.IntegrityCheckIntervalMinutes);
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 service = scope.ServiceProvider.GetRequiredService<ICmsInstanceService>();
await service.VerifyIntegrityAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled error during integrity check tick.");
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Modules.Master.Models;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize(Policy = "OwnerOnly")]
public class CmsInstanceController(ICmsInstanceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var instances = await service.GetAllAsync();
return Ok(instances);
}
[HttpPost]
public async Task<IActionResult> Add([FromBody] CreateCmsInstanceRequest request)
{
try
{
var dto = await service.AddAsync(request);
return CreatedAtAction(nameof(GetAll), null, dto);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request)
{
try
{
var result = await service.UpdateStatusAsync(id, request);
return Ok(result);
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
@@ -0,0 +1,14 @@
namespace SlpModularCms.Modules.Master.Data.Entities;
public class CmsInstance
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public CmsInstanceStatus Status { get; set; } = CmsInstanceStatus.Available;
public string? DisableMessage { get; set; }
public DateTimeOffset? LastContactedAt { get; set; }
public DateTimeOffset? LastStatusPushedAt { get; set; }
public DateTimeOffset? LastIntegrityCheckFailedAt { get; set; }
}
@@ -0,0 +1,8 @@
namespace SlpModularCms.Modules.Master.Data.Entities;
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Data;
public class MasterDbContext(DbContextOptions<MasterDbContext> options) : DbContext(options)
{
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<CmsInstance>(entity =>
{
entity.ToTable("MasterCmsInstances");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Url).IsRequired().HasMaxLength(500);
entity.Property(e => e.ApiKey).IsRequired().HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.DisableMessage).HasMaxLength(500);
});
}
}
@@ -0,0 +1,64 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using SlpModularCms.Core.Modules;
using SlpModularCms.Modules.Master.BackgroundServices;
using SlpModularCms.Modules.Master.Data;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Repositories;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master;
[ExcludeFromCodeCoverage]
public class MasterModule : IModule
{
public string Name => "Master";
public string Version => "1.0.0";
public void RegisterServices(IServiceCollection services)
{
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
services.AddOptions<MasterModuleOptions>().BindConfiguration("MasterModule");
services.AddDbContext<MasterDbContext>((serviceProvider, options) =>
{
var config = serviceProvider.GetRequiredService<IConfiguration>();
options.UseSqlServer(config.GetConnectionString("DefaultConnection"));
});
services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>();
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, context) =>
{
var opts = context.ServiceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MasterModuleOptions>>();
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.Value.HttpTimeoutSeconds));
});
services.AddHostedService<IntegrityCheckBackgroundService>();
services.AddHttpContextAccessor();
}
public void UseModule(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MasterDbContext>();
db.Database.Migrate();
}
}
@@ -0,0 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record CmsInstanceDto(
Guid Id,
string Name,
string Url,
CmsInstanceStatus Status,
string? DisableMessage,
DateTimeOffset? LastContactedAt,
DateTimeOffset? LastStatusPushedAt,
DateTimeOffset? LastIntegrityCheckFailedAt
);
@@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record CreateCmsInstanceRequest(
string Name,
string Url,
string ApiKey
);
@@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record UpdateStatusRequest(
CmsInstanceStatus Status,
string? DisableMessage
);
@@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record UpdateStatusResult(
bool Success,
bool SlaveContactSuccess
);
@@ -0,0 +1,10 @@
namespace SlpModularCms.Modules.Master.Config;
public class MasterModuleOptions
{
public int IntegrityCheckIntervalMinutes { get; set; } = 60;
public int HttpTimeoutSeconds { get; set; } = 10;
public string? MasterUrl { get; set; }
public int CacheMinutes { get; set; } = 60;
public string? ApiKey { get; set; }
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Master.Data;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Repositories;
public class CmsInstanceRepository(MasterDbContext context) : ICmsInstanceRepository
{
public async Task<IReadOnlyList<CmsInstance>> GetAllAsync()
=> await context.CmsInstances.ToListAsync();
public async Task<IReadOnlyList<CmsInstance>> GetActiveAsync()
=> await context.CmsInstances
.Where(c => c.Status != CmsInstanceStatus.Inactive)
.ToListAsync();
public async Task<CmsInstance?> GetByIdAsync(Guid id)
=> await context.CmsInstances.FindAsync(id);
public async Task AddAsync(CmsInstance instance)
=> await context.CmsInstances.AddAsync(instance);
public void Update(CmsInstance instance)
=> context.CmsInstances.Update(instance);
public async Task SaveChangesAsync()
=> await context.SaveChangesAsync();
}
@@ -0,0 +1,13 @@
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Repositories;
public interface ICmsInstanceRepository
{
Task<IReadOnlyList<CmsInstance>> GetAllAsync();
Task<IReadOnlyList<CmsInstance>> GetActiveAsync();
Task<CmsInstance?> GetByIdAsync(Guid id);
Task AddAsync(CmsInstance instance);
void Update(CmsInstance instance);
Task SaveChangesAsync();
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.DataProtection;
namespace SlpModularCms.Modules.Master.Services;
public class ApiKeyProtector(IDataProtectionProvider provider) : IApiKeyProtector
{
private readonly IDataProtector _protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encryptedApiKey) => _protector.Unprotect(encryptedApiKey);
}
@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Master.Data.Entities;
using SlpModularCms.Modules.Master.Models;
namespace SlpModularCms.Modules.Master.Services;
public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceService
{
public async Task<IReadOnlyList<CmsInstanceDto>> GetAllAsync()
{
var instances = await deps.Repository.GetAllAsync();
return instances.Select(ToDto).ToList();
}
public async Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("Name is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.Url))
throw new ArgumentException("Url is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.ApiKey))
throw new ArgumentException("ApiKey is required.", nameof(request));
var encryptedKey = deps.ApiKeyProtector.Protect(request.ApiKey);
var instance = new CmsInstance
{
Id = Guid.NewGuid(),
Name = request.Name,
Url = request.Url,
ApiKey = encryptedKey,
Status = CmsInstanceStatus.Available
};
await deps.Repository.AddAsync(instance);
await deps.Repository.SaveChangesAsync();
var masterUrl = ResolveMasterUrl();
if (masterUrl is not null)
{
var registered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, request.ApiKey, masterUrl);
if (registered)
{
instance.LastContactedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
else
{
deps.Logger.LogWarning("Master registration failed for slave {SlaveUrl}", instance.Url);
}
}
else
{
deps.Logger.LogWarning("MasterUrl could not be determined; skipping slave registration for {SlaveUrl}", instance.Url);
}
return ToDto(instance);
}
public async Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request)
{
var instance = await deps.Repository.GetByIdAsync(id)
?? throw new KeyNotFoundException($"CMS instance {id} not found.");
if (request.Status == CmsInstanceStatus.NotAvailable && string.IsNullOrWhiteSpace(request.DisableMessage))
throw new ArgumentException("DisableMessage is required when setting status to NotAvailable.", nameof(request));
instance.Status = request.Status;
instance.DisableMessage = request.Status == CmsInstanceStatus.Available ? null : request.DisableMessage;
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);
var pushed = await deps.SlaveClient.PushStatusAsync(
instance.Url,
plainKey,
isAvailable: request.Status == CmsInstanceStatus.Available,
disableMessage: instance.DisableMessage);
if (pushed)
{
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
else
{
deps.Logger.LogError("Status push failed for slave {SlaveUrl}", instance.Url);
}
return new UpdateStatusResult(Success: true, SlaveContactSuccess: pushed);
}
public async Task VerifyIntegrityAsync()
{
var masterUrl = ResolveMasterUrl();
if (masterUrl is null)
{
deps.Logger.LogWarning("MasterUrl not configured; skipping integrity check.");
return;
}
var activeInstances = await deps.Repository.GetActiveAsync();
foreach (var instance in activeInstances)
{
try
{
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
var registeredUrl = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
if (registeredUrl is null)
{
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
continue;
}
if (!string.Equals(registeredUrl, masterUrl, StringComparison.OrdinalIgnoreCase))
{
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, registeredUrl);
var reRegistered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, plainKey, masterUrl);
if (reRegistered)
{
instance.LastContactedAt = DateTimeOffset.UtcNow;
instance.LastIntegrityCheckFailedAt = null;
}
else
{
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
}
}
else
{
instance.LastIntegrityCheckFailedAt = null;
}
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
catch (Exception ex)
{
deps.Logger.LogError(ex, "Integrity check error for slave {SlaveUrl}", instance.Url);
}
}
}
private string? ResolveMasterUrl()
{
var httpContext = deps.HttpContextAccessor.HttpContext;
if (httpContext is not null)
{
var request = httpContext.Request;
return $"{request.Scheme}://{request.Host}";
}
return deps.Options.Value.MasterUrl;
}
private static CmsInstanceDto ToDto(CmsInstance instance) => new(
instance.Id,
instance.Name,
instance.Url,
instance.Status,
instance.DisableMessage,
instance.LastContactedAt,
instance.LastStatusPushedAt,
instance.LastIntegrityCheckFailedAt
);
}
@@ -0,0 +1,7 @@
namespace SlpModularCms.Modules.Master.Services;
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
@@ -0,0 +1,11 @@
using SlpModularCms.Modules.Master.Models;
namespace SlpModularCms.Modules.Master.Services;
public interface ICmsInstanceService
{
Task<IReadOnlyList<CmsInstanceDto>> GetAllAsync();
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
Task VerifyIntegrityAsync();
}
@@ -0,0 +1,8 @@
namespace SlpModularCms.Modules.Master.Services;
public interface ISlaveApiClient
{
Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl);
Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
}
@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Repositories;
namespace SlpModularCms.Modules.Master.Services;
[ExcludeFromCodeCoverage]
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
@@ -0,0 +1,65 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace SlpModularCms.Modules.Master.Services;
public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{slaveUrl.TrimEnd('/')}/api/v1/master/register");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
request.Content = JsonContent.Create(new { MasterUrl = masterUrl }, options: JsonOptions);
var response = await httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{slaveUrl.TrimEnd('/')}/api/v1/master/status");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
request.Content = JsonContent.Create(new { IsAvailable = isAvailable, DisableMessage = disableMessage }, options: JsonOptions);
var response = await httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{slaveUrl.TrimEnd('/')}/api/v1/master/registered-url");
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<RegisteredMasterUrlResponse>(JsonOptions);
return result?.MasterUrl;
}
catch
{
return null;
}
}
private record RegisteredMasterUrlResponse(string? MasterUrl);
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SlpModularCms.Modules.Master.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.6.0" />
</ItemGroup>
</Project>