Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
+128
@@ -0,0 +1,128 @@
|
||||
# Logical Components — Unit 1: master-backend
|
||||
|
||||
## Full Component Wiring Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph ServiceLayer["Service Layer"]
|
||||
CmsService["CmsInstanceService"]
|
||||
Deps["MasterServiceDependencies\n(constructor record)"]
|
||||
Repo["ICmsInstanceRepository"]
|
||||
SlaveClientIface["ISlaveApiClient"]
|
||||
ApiKeyProt["IApiKeyProtector"]
|
||||
HttpCtxAcc["IHttpContextAccessor"]
|
||||
Logger["ILogger"]
|
||||
Opts["MasterModuleOptions\n(via IOptions)"]
|
||||
end
|
||||
|
||||
subgraph SecurityLayer["Security Layer"]
|
||||
ApiKeyProtImpl["ApiKeyProtector"]
|
||||
DataProt["IDataProtectionProvider\n(ASP.NET Core)"]
|
||||
Purpose["Purpose string\nSlpModularCms.Master.ApiKey"]
|
||||
end
|
||||
|
||||
subgraph HttpLayer["HTTP + Resilience Layer"]
|
||||
SlaveClientImpl["SlaveApiClient"]
|
||||
PollyPipeline["Polly ResiliencePipeline\nRetry x2 + Timeout"]
|
||||
HttpClientInst["HttpClient\n(IHttpClientFactory)"]
|
||||
end
|
||||
|
||||
subgraph BackgroundLayer["Background Service"]
|
||||
BgSvc["IntegrityCheckBackgroundService"]
|
||||
ScopeFactory["IServiceScopeFactory"]
|
||||
Scope["IServiceScope\n(per tick)"]
|
||||
PeriodicT["PeriodicTimer\n(IntegrityCheckIntervalMinutes)"]
|
||||
end
|
||||
|
||||
subgraph DataLayer["Data Layer"]
|
||||
RepoImpl["CmsInstanceRepository"]
|
||||
DbCtx["MasterDbContext"]
|
||||
Table["CmsInstances table"]
|
||||
end
|
||||
|
||||
CmsService --> Deps
|
||||
Deps --> Repo
|
||||
Deps --> SlaveClientIface
|
||||
Deps --> ApiKeyProt
|
||||
Deps --> HttpCtxAcc
|
||||
Deps --> Logger
|
||||
Deps --> Opts
|
||||
|
||||
ApiKeyProt --> ApiKeyProtImpl
|
||||
ApiKeyProtImpl --> DataProt
|
||||
DataProt --> Purpose
|
||||
|
||||
SlaveClientIface --> SlaveClientImpl
|
||||
SlaveClientImpl --> PollyPipeline
|
||||
PollyPipeline --> HttpClientInst
|
||||
|
||||
Repo --> RepoImpl
|
||||
RepoImpl --> DbCtx
|
||||
DbCtx --> Table
|
||||
|
||||
BgSvc --> PeriodicT
|
||||
BgSvc --> ScopeFactory
|
||||
ScopeFactory --> Scope
|
||||
Scope --> CmsService
|
||||
|
||||
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
||||
classDef security fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
|
||||
classDef http fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
||||
classDef background fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
|
||||
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
|
||||
classDef record fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
|
||||
class CmsService,Repo,SlaveClientIface,ApiKeyProt service
|
||||
class Deps record
|
||||
class ApiKeyProtImpl,DataProt,Purpose security
|
||||
class SlaveClientImpl,PollyPipeline,HttpClientInst,HttpCtxAcc http
|
||||
class BgSvc,ScopeFactory,Scope,PeriodicT background
|
||||
class RepoImpl,DbCtx,Table,Logger,Opts data
|
||||
```
|
||||
|
||||
Text alternative: CmsInstanceService receives all dependencies via MasterServiceDependencies record; IApiKeyProtector wraps Data Protection; ISlaveApiClient wraps SlaveApiClient backed by Polly pipeline; ICmsInstanceRepository wraps MasterDbContext; IntegrityCheckBackgroundService creates a new IServiceScope per PeriodicTimer tick to resolve CmsInstanceService.
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibility Summary
|
||||
|
||||
| Component | Type | NFR Pattern |
|
||||
|-----------|------|------------|
|
||||
| `MasterServiceDependencies` | Record | Constructor aggregation (reduces constructor arity) |
|
||||
| `IApiKeyProtector` / `ApiKeyProtector` | Interface + Singleton | Security — Data Protection wrapper; mock-friendly |
|
||||
| `SlaveApiClient` | Typed HTTP client | Resilience — Polly retry + timeout applied via `AddResilienceHandler` |
|
||||
| `IntegrityCheckBackgroundService` | Singleton `BackgroundService` | Reliability — per-tick `IServiceScope`; exception isolation per slave |
|
||||
| `MasterDbContext` | EF Core DbContext | Maintainability — per-module migrations; own connection |
|
||||
| `CmsInstanceService` | Scoped service | Orchestration — resolved via `IServiceScope` by background service |
|
||||
|
||||
---
|
||||
|
||||
## DI Registration Order (in `MasterModule.RegisterServices`)
|
||||
|
||||
```
|
||||
1. services.AddDataProtection()
|
||||
2. services.AddSingleton<IApiKeyProtector, ApiKeyProtector>()
|
||||
3. services.Configure<MasterModuleOptions>(config.GetSection("MasterModule"))
|
||||
4. services.AddDbContext<MasterDbContext>(...)
|
||||
5. services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>()
|
||||
6. services.AddScoped<MasterServiceDependencies>()
|
||||
7. services.AddScoped<ICmsInstanceService, CmsInstanceService>()
|
||||
8. services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
|
||||
.AddResilienceHandler("slave-resilience", ...)
|
||||
9. services.AddHostedService<IntegrityCheckBackgroundService>()
|
||||
10. services.AddHttpContextAccessor() (if not already registered by host)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NFR Coverage Traceability
|
||||
|
||||
| NFR | Pattern Applied | Component |
|
||||
|-----|----------------|-----------|
|
||||
| Fail-open (NFR-MASTER-01) | `SlaveApiClient` catches failures, returns `false`; service continues | `SlaveApiClient`, `CmsInstanceService` |
|
||||
| API key security (NFR-MASTER-03) | `IApiKeyProtector` wraps Data Protection; never returns key in DTO | `ApiKeyProtector`, `CmsInstanceDto` mapping |
|
||||
| Configurable interval (NFR-MASTER-04) | `PeriodicTimer` reads `MasterModuleOptions.IntegrityCheckIntervalMinutes` | `IntegrityCheckBackgroundService` |
|
||||
| ≥80% test coverage (NFR-MASTER-05) | All service/repository/client classes have interfaces; `MasterServiceDependencies` simplifies test setup | All interfaces |
|
||||
| Per-module migrations (NFR-MASTER-06) | `MasterDbContext` with own migration assembly; applied in `UseModule` | `MasterDbContext`, `MasterModule` |
|
||||
| Retry resilience (Q2) | Polly exponential backoff on `IHttpClientBuilder` | `SlaveApiClient` registration |
|
||||
| Timeout (Q1) | Polly `AddTimeout` per attempt, driven by `HttpTimeoutSeconds` | `SlaveApiClient` registration |
|
||||
| Logging levels (Q5) | `Error` for status push failures; `Warning` for integrity check failures | `CmsInstanceService`, `IntegrityCheckBackgroundService` |
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# NFR Design Patterns — Unit 1: master-backend
|
||||
|
||||
## Pattern 1 — Resilience: Polly Pipeline via `AddResilienceHandler`
|
||||
|
||||
**NFR**: Exponential backoff (3 attempts), per-attempt timeout (`HttpTimeoutSeconds`)
|
||||
|
||||
**Pattern**: Single shared resilience pipeline registered on the `IHttpClientBuilder` for `SlaveApiClient`. All HTTP calls from `SlaveApiClient` pass through the pipeline automatically — no per-method boilerplate.
|
||||
|
||||
**Pipeline composition** (outer → inner execution order):
|
||||
1. **Retry** — `AddRetry` with exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries on `HttpRequestException` and non-2xx responses
|
||||
2. **Timeout** — `AddTimeout` with `TimeSpan.FromSeconds(MasterModuleOptions.HttpTimeoutSeconds)`; applied per attempt (not total)
|
||||
|
||||
**Registration** (in `MasterModule.RegisterServices`):
|
||||
```csharp
|
||||
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
|
||||
.AddResilienceHandler("slave-resilience", (builder, ctx) =>
|
||||
{
|
||||
var opts = ctx.ServiceProvider
|
||||
.GetRequiredService<IOptions<MasterModuleOptions>>().Value;
|
||||
|
||||
builder.AddRetry(new HttpRetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = 2,
|
||||
Delay = TimeSpan.FromSeconds(1),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
ShouldHandle = args => ValueTask.FromResult(
|
||||
args.Outcome.Exception is HttpRequestException ||
|
||||
(args.Outcome.Result?.IsSuccessStatusCode == false))
|
||||
});
|
||||
builder.AddTimeout(TimeSpan.FromSeconds(opts.HttpTimeoutSeconds));
|
||||
});
|
||||
```
|
||||
|
||||
**Retry flow**:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Call["SlaveApiClient HTTP call"]
|
||||
Attempt["Execute HTTP request\n(with per-attempt timeout)"]
|
||||
Success{"Response\nsuccessful?"}
|
||||
ReturnOk["Return result"]
|
||||
MaxReached{"Max attempts\n(3) reached?"}
|
||||
Backoff["Wait exponential delay\n1s or 2s plus jitter"]
|
||||
ReturnFail["Return false\nor throw on final attempt"]
|
||||
|
||||
Call --> Attempt --> Success
|
||||
Success -->|"yes"| ReturnOk
|
||||
Success -->|"no"| MaxReached
|
||||
MaxReached -->|"yes"| ReturnFail
|
||||
MaxReached -->|"no"| Backoff --> Attempt
|
||||
|
||||
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
|
||||
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
||||
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
||||
classDef fail fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
||||
class Success,MaxReached decision
|
||||
class Call,Attempt,Backoff action
|
||||
class ReturnOk terminal
|
||||
class ReturnFail fail
|
||||
```
|
||||
|
||||
Text alternative: HTTP call enters pipeline; per-attempt timeout applies; on failure checks if max attempts reached; if not waits exponential delay and retries; after 3 total failures returns false.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2 — Security: `IApiKeyProtector` Wrapper
|
||||
|
||||
**NFR**: ApiKey encrypted at rest; decrypted only for HTTP calls; never exposed in responses
|
||||
|
||||
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Keeps `CmsInstanceService` independent of Data Protection internals and makes unit tests trivially simple (mock returns plain strings).
|
||||
|
||||
**Interface**:
|
||||
```csharp
|
||||
public interface IApiKeyProtector
|
||||
{
|
||||
string Protect(string plainApiKey);
|
||||
string Unprotect(string encryptedApiKey);
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```csharp
|
||||
public class ApiKeyProtector : IApiKeyProtector
|
||||
{
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public ApiKeyProtector(IDataProtectionProvider provider)
|
||||
{
|
||||
_protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
|
||||
}
|
||||
|
||||
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
|
||||
public string Unprotect(string encrypted) => _protector.Unprotect(encrypted);
|
||||
}
|
||||
```
|
||||
|
||||
**Registration** (in `MasterModule.RegisterServices`):
|
||||
```csharp
|
||||
services.AddDataProtection();
|
||||
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
|
||||
```
|
||||
|
||||
**Usage in tests**:
|
||||
```csharp
|
||||
var protector = new Mock<IApiKeyProtector>();
|
||||
protector.Setup(p => p.Protect(It.IsAny<string>())).Returns((string s) => $"enc:{s}");
|
||||
protector.Setup(p => p.Unprotect(It.IsAny<string>())).Returns((string s) => s.Replace("enc:", ""));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3 — Constructor Aggregation: `MasterServiceDependencies`
|
||||
|
||||
**Rationale**: `CmsInstanceService` requires 6 dependencies. Wrapping them in a record removes constructor noise and groups related parameters semantically.
|
||||
|
||||
**Record definition**:
|
||||
```csharp
|
||||
public record MasterServiceDependencies(
|
||||
ICmsInstanceRepository Repository,
|
||||
ISlaveApiClient SlaveClient,
|
||||
IApiKeyProtector ApiKeyProtector,
|
||||
IOptions<MasterModuleOptions> Options,
|
||||
IHttpContextAccessor HttpContextAccessor,
|
||||
ILogger<CmsInstanceService> Logger
|
||||
);
|
||||
```
|
||||
|
||||
**Registration** (framework resolves all fields automatically):
|
||||
```csharp
|
||||
services.AddScoped<MasterServiceDependencies>();
|
||||
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
|
||||
```
|
||||
|
||||
**`CmsInstanceService` constructor**:
|
||||
```csharp
|
||||
public CmsInstanceService(MasterServiceDependencies deps)
|
||||
{
|
||||
_deps = deps;
|
||||
}
|
||||
```
|
||||
|
||||
**Test construction** (explicit, no DI container needed):
|
||||
```csharp
|
||||
var deps = new MasterServiceDependencies(
|
||||
mockRepo.Object,
|
||||
mockSlaveClient.Object,
|
||||
mockProtector.Object,
|
||||
Options.Create(new MasterModuleOptions()),
|
||||
mockHttpContextAccessor.Object,
|
||||
NullLogger<CmsInstanceService>.Instance
|
||||
);
|
||||
var svc = new CmsInstanceService(deps);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4 — Background Service Scope Isolation
|
||||
|
||||
**NFR**: `ICmsInstanceService` is Scoped; `IntegrityCheckBackgroundService` is Singleton
|
||||
|
||||
**Pattern**: Create and dispose a dedicated `IServiceScope` per tick. No singleton scope leakage.
|
||||
|
||||
```csharp
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(
|
||||
TimeSpan.FromMinutes(_options.Value.IntegrityCheckIntervalMinutes));
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
var svc = scope.ServiceProvider
|
||||
.GetRequiredService<ICmsInstanceService>();
|
||||
await svc.VerifyIntegrityAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during integrity check tick");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5 — Structured Logging
|
||||
|
||||
**Pattern**: Log with structured fields; never log the raw `ApiKey` value.
|
||||
|
||||
| Scenario | Level | Fields |
|
||||
|----------|-------|--------|
|
||||
| Status push failed | `Error` | `{InstanceId}`, `{SlaveUrl}`, `{Exception}` |
|
||||
| Integrity check: slave unreachable | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
|
||||
| Integrity check: URL mismatch | `Warning` | `{InstanceId}`, `{SlaveUrl}`, `{ExpectedUrl}`, `{RegisteredUrl}` |
|
||||
| Integrity check: re-registration ok | `Information` | `{InstanceId}`, `{SlaveUrl}` |
|
||||
| Integrity check: re-registration failed | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
|
||||
| ApiKey decryption failure | `Error` | `{InstanceId}` — do NOT log key material |
|
||||
| Tick started | `Debug` | `{ActiveInstanceCount}` |
|
||||
|
||||
**Example**:
|
||||
```csharp
|
||||
_logger.LogWarning(
|
||||
"Slave {InstanceId} at {SlaveUrl} is unreachable during integrity check",
|
||||
instance.Id, instance.Url);
|
||||
```
|
||||
Reference in New Issue
Block a user