Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
# Code Summary — Unit 1: master-backend
|
||||
|
||||
## New Project: SlpModularCms.Modules.Master
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `SlpModularCms.Modules.Master.csproj` | Project file; references Core; adds Microsoft.Extensions.Http.Resilience 9.6.0 |
|
||||
| `Options/MasterModuleOptions.cs` | Configuration POCO: IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl, CacheMinutes, ApiKey |
|
||||
| `Data/Entities/CmsInstanceStatus.cs` | Enum: Available=0, NotAvailable=1, Inactive=2 |
|
||||
| `Data/Entities/CmsInstance.cs` | EF Core entity with all domain fields including LastIntegrityCheckFailedAt |
|
||||
| `Data/MasterDbContext.cs` | Per-module DbContext; table MasterCmsInstances; configured via OnModelCreating |
|
||||
| `Models/CmsInstanceDto.cs` | Record DTO (excludes ApiKey); ExcludeFromCodeCoverage |
|
||||
| `Models/CreateCmsInstanceRequest.cs` | Record request for POST; ExcludeFromCodeCoverage |
|
||||
| `Models/UpdateStatusRequest.cs` | Record request for status update; ExcludeFromCodeCoverage |
|
||||
| `Models/UpdateStatusResult.cs` | Record result with Success + SlaveContactSuccess; ExcludeFromCodeCoverage |
|
||||
| `Repositories/ICmsInstanceRepository.cs` | Interface: GetAllAsync, GetActiveAsync, GetByIdAsync, AddAsync, Update, SaveChangesAsync |
|
||||
| `Repositories/CmsInstanceRepository.cs` | EF Core implementation; GetActiveAsync excludes Inactive |
|
||||
| `Services/IApiKeyProtector.cs` | Interface: Protect/Unprotect |
|
||||
| `Services/ApiKeyProtector.cs` | Data Protection wrapper; purpose string "SlpModularCms.Master.ApiKey" |
|
||||
| `Services/ISlaveApiClient.cs` | Interface: RegisterMasterAsync, PushStatusAsync, GetRegisteredMasterUrlAsync |
|
||||
| `Services/SlaveApiClient.cs` | Typed HTTP client; X-Master-Api-Key header on each call; fail-open (returns false on exception) |
|
||||
| `Services/MasterServiceDependencies.cs` | Record aggregating 6 CmsInstanceService dependencies; ExcludeFromCodeCoverage |
|
||||
| `Services/ICmsInstanceService.cs` | Interface: GetAllAsync, AddAsync, UpdateStatusAsync, VerifyIntegrityAsync |
|
||||
| `Services/CmsInstanceService.cs` | Business logic; HttpContext → config fallback for MasterUrl; never returns ApiKey in DTO |
|
||||
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | PeriodicTimer; per-tick IServiceScope; catches all exceptions per tick |
|
||||
| `Controllers/CmsInstanceController.cs` | [Authorize(Policy="OwnerOnly")]; GET / POST / PUT /{id}/status |
|
||||
| `MasterModule.cs` | IModule implementation; DI registration; db.Database.Migrate() in UseModule; ExcludeFromCodeCoverage |
|
||||
| `Migrations/.gitkeep` | Placeholder; run CLI to generate migration (see below) |
|
||||
|
||||
## New Project: SlpModularCms.Modules.Master.Tests
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `SlpModularCms.Modules.Master.Tests.csproj` | xUnit + NSubstitute + FluentAssertions + EF InMemory |
|
||||
| `Repositories/CmsInstanceRepositoryTests.cs` | EF InMemory; covers all repository methods |
|
||||
| `Services/ApiKeyProtectorTests.cs` | Uses EphemeralDataProtectionProvider; round-trip + invalid ciphertext tests |
|
||||
| `Services/SlaveApiClientTests.cs` | FakeHttpMessageHandler; tests success/failure/exception paths + header assertion |
|
||||
| `Services/CmsInstanceServiceTests.cs` | NSubstitute; covers all business logic branches including HttpContext fallback |
|
||||
| `BackgroundServices/IntegrityCheckBackgroundServiceTests.cs` | PeriodicTimer integration; verifies exception isolation |
|
||||
| `Controllers/CmsInstanceControllerTests.cs` | NSubstitute ICmsInstanceService; verifies all HTTP response codes |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Added ProjectReference to SlpModularCms.Modules.Master |
|
||||
| `SlpModularCms.sln` | Added both new projects with GUIDs and src folder nesting |
|
||||
|
||||
## EF Core Migration
|
||||
|
||||
After building the solution, run:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
|
||||
```
|
||||
|
||||
This generates the `Migrations/` folder contents. The migration is applied automatically on startup via `db.Database.Migrate()` in `MasterModule.UseModule`.
|
||||
|
||||
## Notes
|
||||
|
||||
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
|
||||
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
|
||||
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# Business Logic Model — Unit 1: master-backend
|
||||
|
||||
## Flow 1 — AddAsync (Add Slave CMS)
|
||||
|
||||
**Trigger**: `POST /api/v1/CmsInstances` (Owner only)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(99,179,237,0.3) API Layer
|
||||
participant Ctrl as CmsInstanceController
|
||||
end
|
||||
box rgba(154,230,180,0.3) Service Layer
|
||||
participant Svc as CmsInstanceService
|
||||
participant Repo as CmsInstanceRepository
|
||||
participant Client as SlaveApiClient
|
||||
end
|
||||
box rgba(246,224,94,0.3) Infrastructure
|
||||
participant DP as IDataProtector
|
||||
participant Ctx as IHttpContextAccessor
|
||||
participant Opts as MasterModuleOptions
|
||||
end
|
||||
box rgba(200,200,200,0.3) Persistence
|
||||
participant DB as MasterDbContext
|
||||
end
|
||||
|
||||
Ctrl->>Svc: AddAsync(request)
|
||||
Note over Svc: Validate Name, Url, ApiKey not empty
|
||||
Note over Svc: Validate Url starts with http or https
|
||||
Svc->>DP: Protect(request.ApiKey)
|
||||
DP-->>Svc: encryptedApiKey
|
||||
Svc->>Repo: AddAsync(new CmsInstance)
|
||||
Note over Svc,Repo: Status=Available, LastContactedAt=null
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: INSERT CmsInstances
|
||||
Note over Svc: Determine masterUrl
|
||||
Svc->>Ctx: try get base URL from HttpContext
|
||||
alt HttpContext available
|
||||
Ctx-->>Svc: masterUrl from request
|
||||
else HttpContext unavailable
|
||||
Svc->>Opts: read MasterUrl
|
||||
Opts-->>Svc: configured masterUrl
|
||||
end
|
||||
Svc->>DP: Unprotect(encryptedApiKey)
|
||||
DP-->>Svc: plainApiKey
|
||||
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
|
||||
alt Registration success
|
||||
Client-->>Svc: true
|
||||
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
else Registration failed
|
||||
Client-->>Svc: false
|
||||
Note over Svc: LastContactedAt stays null (owner can see)
|
||||
end
|
||||
Svc-->>Ctrl: CmsInstanceDto
|
||||
Ctrl-->>Ctrl: return 201 Created
|
||||
```
|
||||
|
||||
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
|
||||
|
||||
---
|
||||
|
||||
## Flow 2 — UpdateStatusAsync (Set Slave Status)
|
||||
|
||||
**Trigger**: `PUT /api/v1/CmsInstances/{id}/status` (Owner only)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(99,179,237,0.3) API Layer
|
||||
participant Ctrl as CmsInstanceController
|
||||
end
|
||||
box rgba(154,230,180,0.3) Service Layer
|
||||
participant Svc as CmsInstanceService
|
||||
participant Repo as CmsInstanceRepository
|
||||
participant Client as SlaveApiClient
|
||||
end
|
||||
box rgba(246,224,94,0.3) Infrastructure
|
||||
participant DP as IDataProtector
|
||||
end
|
||||
box rgba(200,200,200,0.3) Persistence
|
||||
participant DB as MasterDbContext
|
||||
end
|
||||
|
||||
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
|
||||
Svc->>Repo: GetByIdAsync(id)
|
||||
Repo->>DB: SELECT CmsInstances WHERE Id
|
||||
DB-->>Repo: CmsInstance or null
|
||||
Repo-->>Svc: entity or null
|
||||
alt Entity not found
|
||||
Svc-->>Ctrl: throw NotFoundException
|
||||
end
|
||||
Note over Svc: Validate DisableMessage required if NotAvailable
|
||||
alt Validation fails
|
||||
Svc-->>Ctrl: throw ValidationException
|
||||
end
|
||||
alt newStatus = Inactive
|
||||
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
|
||||
else newStatus = Available or NotAvailable
|
||||
Svc->>Repo: UpdateAsync (Status, DisableMessage)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
Svc->>DP: Unprotect(entity.ApiKey)
|
||||
DP-->>Svc: plainApiKey
|
||||
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
|
||||
alt Push success
|
||||
Client-->>Svc: true
|
||||
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
|
||||
else Push failed
|
||||
Client-->>Svc: false
|
||||
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
|
||||
end
|
||||
end
|
||||
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
|
||||
```
|
||||
|
||||
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
|
||||
|
||||
---
|
||||
|
||||
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
|
||||
|
||||
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(200,200,200,0.3) Background
|
||||
participant Timer as PeriodicTimer
|
||||
participant BgSvc as IntegrityCheckBackgroundService
|
||||
end
|
||||
box rgba(154,230,180,0.3) Service Layer
|
||||
participant Svc as CmsInstanceService
|
||||
participant Repo as CmsInstanceRepository
|
||||
participant Client as SlaveApiClient
|
||||
end
|
||||
box rgba(246,224,94,0.3) Infrastructure
|
||||
participant DP as IDataProtector
|
||||
participant Opts as MasterModuleOptions
|
||||
end
|
||||
box rgba(200,200,200,0.3) Persistence
|
||||
participant DB as MasterDbContext
|
||||
end
|
||||
|
||||
Timer->>BgSvc: Tick
|
||||
BgSvc->>Svc: VerifyIntegrityAsync()
|
||||
Svc->>Repo: GetActiveAsync()
|
||||
Repo->>DB: SELECT WHERE Status != Inactive
|
||||
DB-->>Repo: list of CmsInstance
|
||||
Repo-->>Svc: instances
|
||||
loop for each instance
|
||||
Svc->>DP: Unprotect(instance.ApiKey)
|
||||
DP-->>Svc: plainApiKey
|
||||
Svc->>Opts: read MasterUrl
|
||||
Opts-->>Svc: masterUrl
|
||||
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
|
||||
alt Slave unreachable
|
||||
Client-->>Svc: throws or returns null
|
||||
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
else Slave reachable
|
||||
Client-->>Svc: registeredMasterUrl
|
||||
alt URLs match
|
||||
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
else URL mismatch
|
||||
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
|
||||
alt Re-registration success
|
||||
Client-->>Svc: true
|
||||
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
else Re-registration failed
|
||||
Client-->>Svc: false
|
||||
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
|
||||
Svc->>Repo: SaveChangesAsync()
|
||||
Repo->>DB: UPDATE CmsInstances
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Svc-->>BgSvc: done
|
||||
```
|
||||
|
||||
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# Business Rules — Unit 1: master-backend
|
||||
|
||||
## BR-01 — Status Update Decision Logic
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Start(["UpdateStatusAsync called"])
|
||||
CheckExists{"Entity exists\nfor given id?"}
|
||||
NotFound["Throw NotFoundException\n404 to caller"]
|
||||
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
|
||||
ValidationErr["Throw ValidationException\nDisableMessage required"]
|
||||
CheckInactive{"newStatus\n= Inactive?"}
|
||||
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
|
||||
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
|
||||
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
|
||||
PushSlave["PushStatusAsync\nto slave endpoint"]
|
||||
PushOk{"HTTP push\nsucceeded?"}
|
||||
UpdatePushed["LastStatusPushedAt = UtcNow\nSave"]
|
||||
ReturnOk["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=true"]
|
||||
ReturnWarn["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=false"]
|
||||
Done(["Return result to controller"])
|
||||
|
||||
Start --> CheckExists
|
||||
CheckExists -->|"no"| NotFound
|
||||
CheckExists -->|"yes"| CheckMsg
|
||||
CheckMsg -->|"yes — invalid"| ValidationErr
|
||||
CheckMsg -->|"no — valid"| CheckInactive
|
||||
CheckInactive -->|"yes"| SetInactive --> Done
|
||||
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
|
||||
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
|
||||
PushOk -->|"no"| ReturnWarn --> Done
|
||||
|
||||
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 error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
||||
class CheckExists,CheckMsg,CheckInactive,PushOk decision
|
||||
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
|
||||
class Start,Done terminal
|
||||
class NotFound,ValidationErr error
|
||||
```
|
||||
|
||||
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
|
||||
|
||||
---
|
||||
|
||||
## BR-02 — Integrity Check Decision Logic
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Start(["VerifyIntegrityAsync\nper instance"])
|
||||
GetUrl["GetRegisteredMasterUrlAsync\n(slaveUrl, plainApiKey)"]
|
||||
Reachable{"Slave\nreachable?"}
|
||||
SetFailed["LastIntegrityCheckFailedAt = UtcNow\nSave — continue to next"]
|
||||
UrlMatch{"registeredMasterUrl\n= expected masterUrl?"}
|
||||
ClearOk["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
|
||||
ReRegister["RegisterMasterAsync\n(slaveUrl, plainApiKey, masterUrl)"]
|
||||
RegOk{"Re-registration\nsucceeded?"}
|
||||
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
|
||||
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
|
||||
Next(["Next instance"])
|
||||
|
||||
Start --> GetUrl --> Reachable
|
||||
Reachable -->|"no"| SetFailed --> Next
|
||||
Reachable -->|"yes"| UrlMatch
|
||||
UrlMatch -->|"match"| ClearOk --> Next
|
||||
UrlMatch -->|"mismatch"| ReRegister --> RegOk
|
||||
RegOk -->|"yes"| ClearAfterReg --> Next
|
||||
RegOk -->|"no"| SetFailedReg --> Next
|
||||
|
||||
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 error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
||||
class Reachable,UrlMatch,RegOk decision
|
||||
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
|
||||
class Start,Next terminal
|
||||
```
|
||||
|
||||
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Field | Condition | Error |
|
||||
|------|-------|-----------|-------|
|
||||
| BR-VAL-01 | `Name` | Must not be null or whitespace | `Name is required` |
|
||||
| BR-VAL-02 | `Url` | Must not be null or whitespace | `Url is required` |
|
||||
| BR-VAL-03 | `Url` | Must start with `http://` or `https://` | `Url must be a valid absolute HTTP(S) URL` |
|
||||
| BR-VAL-04 | `ApiKey` | Must not be null or whitespace (on create) | `ApiKey is required` |
|
||||
| BR-VAL-05 | `DisableMessage` | Required (non-empty) when `Status = NotAvailable` | `DisableMessage is required when status is NotAvailable` |
|
||||
| BR-VAL-06 | `Status` | Must be a valid `CmsInstanceStatus` enum value | `Invalid status value` |
|
||||
| BR-VAL-07 | `id` (update) | CmsInstance with given id must exist | `CmsInstance not found` (404) |
|
||||
|
||||
---
|
||||
|
||||
## Status Transition Rules
|
||||
|
||||
| From | To | DisableMessage | HTTP Push | Notes |
|
||||
|------|----|---------------|-----------|-------|
|
||||
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
|
||||
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
|
||||
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
|
||||
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
|
||||
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
|
||||
|
||||
---
|
||||
|
||||
## ApiKey Encryption Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| BR-ENC-01 | `ApiKey` is encrypted via `IDataProtector` before writing to `MasterDbContext` |
|
||||
| BR-ENC-02 | `ApiKey` is decrypted via `IDataProtector` immediately before each HTTP call requiring it |
|
||||
| BR-ENC-03 | `ApiKey` is **never** included in `CmsInstanceDto` or any other API response |
|
||||
| BR-ENC-04 | `ApiKey` is accepted in `CreateCmsInstanceRequest` on creation only; no update endpoint for ApiKey |
|
||||
|
||||
---
|
||||
|
||||
## Master URL Resolution Rules
|
||||
|
||||
| Context | Resolution Strategy |
|
||||
|---------|-------------------|
|
||||
| Controller-originated calls (Add) | Derive from `HttpContext.Request` scheme + host + (optional port) via `IHttpContextAccessor` |
|
||||
| Background service calls (Integrity Check) | Read `MasterModuleOptions.MasterUrl` from configuration |
|
||||
| `MasterModuleOptions.MasterUrl` is null in background context | Log a warning; skip registration/integrity for that cycle |
|
||||
|
||||
---
|
||||
|
||||
## HTTP Contact Exclusion Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
|
||||
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
|
||||
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
|
||||
|
||||
---
|
||||
|
||||
## Background Service Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| BR-BG-01 | `IntegrityCheckBackgroundService` resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (not injected directly, as service is Scoped) |
|
||||
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
|
||||
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
|
||||
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Domain Entities — Unit 1: master-backend
|
||||
|
||||
## Entity Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MasterDbCtx["MasterDbContext\n(per-module EF Core DbContext)"]
|
||||
CmsInst["CmsInstance\n(aggregate root)"]
|
||||
Status["CmsInstanceStatus\n(enum)"]
|
||||
Opts["MasterModuleOptions\n(config POCO)"]
|
||||
DP["IDataProtector\n(ApiKey encryption)"]
|
||||
DTO["CmsInstanceDto\n(API response shape)"]
|
||||
CreateReq["CreateCmsInstanceRequest\n(API input)"]
|
||||
UpdateReq["UpdateStatusRequest\n(API input)"]
|
||||
UpdateRes["UpdateStatusResult\n(API response for status update)"]
|
||||
|
||||
MasterDbCtx -->|"owns"| CmsInst
|
||||
CmsInst -->|"has"| Status
|
||||
CmsInst -->|"ApiKey encrypted via"| DP
|
||||
CmsInst -->|"projected to"| DTO
|
||||
CreateReq -->|"creates"| CmsInst
|
||||
UpdateReq -->|"mutates status of"| CmsInst
|
||||
UpdateRes -->|"returned from UpdateStatusAsync"| CmsInst
|
||||
Opts -->|"IntegrityCheckIntervalMinutes"| MasterDbCtx
|
||||
Opts -->|"MasterUrl fallback"| MasterDbCtx
|
||||
|
||||
classDef entity fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
|
||||
classDef infra fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
|
||||
classDef dto fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
|
||||
classDef config fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
|
||||
class CmsInst entity
|
||||
class MasterDbCtx,DP infra
|
||||
class Status,DTO,CreateReq,UpdateReq,UpdateRes dto
|
||||
class Opts config
|
||||
```
|
||||
|
||||
Text alternative: MasterDbContext owns CmsInstance; CmsInstance has a Status enum and its ApiKey is encrypted via IDataProtector; request/response shapes map to and from CmsInstance.
|
||||
|
||||
---
|
||||
|
||||
## CmsInstance
|
||||
|
||||
**Table**: `CmsInstances` (owned by `MasterDbContext`, migrations in `SlpModularCms.Modules.Master`)
|
||||
|
||||
| Field | Type | Nullable | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `Id` | `Guid` | No | Primary key |
|
||||
| `Name` | `string` | No | Friendly display name; required |
|
||||
| `Url` | `string` | No | Base URL of slave CMS API; must start with `http://` or `https://` |
|
||||
| `ApiKey` | `string` | No | Encrypted via ASP.NET Core Data Protection before storage; decrypted before HTTP calls |
|
||||
| `Status` | `CmsInstanceStatus` | No | Default: `Available` on creation |
|
||||
| `DisableMessage` | `string?` | Yes | Required when `Status = NotAvailable`; null otherwise |
|
||||
| `LastContactedAt` | `DateTimeOffset?` | Yes | Null = never successfully contacted; set on successful registration or integrity check |
|
||||
| `LastStatusPushedAt` | `DateTimeOffset?` | Yes | Null = status never successfully pushed; set after successful `PushStatusAsync` |
|
||||
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Yes | Null = no pending failure; set when integrity check cannot reach slave or re-registration fails; cleared on next successful contact |
|
||||
|
||||
---
|
||||
|
||||
## CmsInstanceStatus
|
||||
|
||||
```csharp
|
||||
public enum CmsInstanceStatus
|
||||
{
|
||||
Available = 0,
|
||||
NotAvailable = 1,
|
||||
Inactive = 2,
|
||||
}
|
||||
```
|
||||
|
||||
| Value | Meaning | Master Contacts Slave? |
|
||||
|-------|---------|----------------------|
|
||||
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
|
||||
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
|
||||
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
|
||||
|
||||
---
|
||||
|
||||
## MasterModuleOptions
|
||||
|
||||
**Config section**: `"MasterModule"` in `appsettings.json`
|
||||
|
||||
| Property | Type | Default | Side | Notes |
|
||||
|----------|------|---------|------|-------|
|
||||
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master | Interval for `IntegrityCheckBackgroundService` |
|
||||
| `MasterUrl` | `string?` | `null` | Master | Fallback public URL of this master CMS; used by background service when `HttpContext` is unavailable |
|
||||
| `CacheMinutes` | `int` | `60` | Slave | Slave pull cache interval (used by Unit 2) |
|
||||
| `ApiKey` | `string?` | `null` | Slave | Slave API key for validating incoming master requests (used by Unit 2) |
|
||||
|
||||
---
|
||||
|
||||
## CmsInstanceDto (API Response)
|
||||
|
||||
**Rule**: `ApiKey` is **never** included (NFR-MASTER-03).
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `Id` | `Guid` | |
|
||||
| `Name` | `string` | |
|
||||
| `Url` | `string` | |
|
||||
| `Status` | `string` | Serialized as string (`"Available"` / `"NotAvailable"` / `"Inactive"`) |
|
||||
| `DisableMessage` | `string?` | |
|
||||
| `LastContactedAt` | `DateTimeOffset?` | |
|
||||
| `LastStatusPushedAt` | `DateTimeOffset?` | |
|
||||
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Visible in UI so owner knows which slaves have pending check failures |
|
||||
|
||||
---
|
||||
|
||||
## CreateCmsInstanceRequest (API Input)
|
||||
|
||||
| Property | Type | Validation |
|
||||
|----------|------|-----------|
|
||||
| `Name` | `string` | Required, non-empty |
|
||||
| `Url` | `string` | Required; must start with `http://` or `https://` |
|
||||
| `ApiKey` | `string` | Required, non-empty |
|
||||
|
||||
---
|
||||
|
||||
## UpdateStatusRequest (API Input)
|
||||
|
||||
| Property | Type | Validation |
|
||||
|----------|------|-----------|
|
||||
| `Status` | `CmsInstanceStatus` | Required; must be valid enum value |
|
||||
| `DisableMessage` | `string?` | Required and non-empty when `Status = NotAvailable`; ignored otherwise |
|
||||
|
||||
---
|
||||
|
||||
## UpdateStatusResult (Service Return / API Response)
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
|
||||
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
|
||||
+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);
|
||||
```
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# NFR Requirements — Unit 1: master-backend
|
||||
|
||||
## Performance
|
||||
|
||||
| Requirement | Specification | Source |
|
||||
|-------------|--------------|--------|
|
||||
| HTTP timeout for slave calls | Configurable via `MasterModuleOptions.HttpTimeoutSeconds`; default **10 seconds** | Q1 |
|
||||
| HTTP retry budget | Maximum 3 attempts (initial + 2 retries) with exponential backoff (1s, 2s); per-attempt timeout applies | Q2 |
|
||||
| Background service interval | Configurable via `MasterModuleOptions.IntegrityCheckIntervalMinutes`; default 60 minutes | NFR-MASTER-04 |
|
||||
| Controller endpoint latency | No explicit SLA; bounded by HTTP timeout × retry attempts (worst case ~33s for a single unresponsive slave during Add/UpdateStatus) | derived |
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
| Requirement | Specification | Source |
|
||||
|-------------|--------------|--------|
|
||||
| ApiKey at-rest encryption | Encrypted with ASP.NET Core Data Protection before writing to DB; decrypted immediately before HTTP calls | Q3 (+ functional design) |
|
||||
| Data Protection key storage | **Default file system** (platform default); machine-bound; acceptable for single-instance deployment | Q3 |
|
||||
| ApiKey exposure | Never included in `CmsInstanceDto` or any API response; `[JsonIgnore]` or explicit DTO mapping | NFR-MASTER-03 |
|
||||
| Endpoint authorization | All `CmsInstanceController` actions require `[Authorize(Policy = "OwnerOnly")]` | FR-MASTER-10 |
|
||||
| Internal slave endpoint auth | `POST /api/internal/master/register` validated via `X-Master-Api-Key` header (Unit 2 concern) | FR-MASTER-03 |
|
||||
|
||||
---
|
||||
|
||||
## Reliability
|
||||
|
||||
| Requirement | Specification | Source |
|
||||
|-------------|--------------|--------|
|
||||
| Fail-open on slave unreachability | Status push failure returns `SlaveContactSuccess = false` but does not roll back DB change; integrity check sets `LastIntegrityCheckFailedAt` and continues | NFR-MASTER-01 |
|
||||
| Retry policy | Exponential backoff: attempt 1 (immediate), attempt 2 (+1s delay), attempt 3 (+2s delay); implemented via Polly `ResiliencePipeline` | Q2 |
|
||||
| Background service isolation | Exceptions per slave instance are caught, logged, and do not abort the full integrity check batch | BR-BG-03 |
|
||||
| Background service scope | `IServiceScopeFactory` used per tick to resolve scoped `ICmsInstanceService`; scope disposed after each tick | BR-BG-01/02 |
|
||||
|
||||
---
|
||||
|
||||
## Testability
|
||||
|
||||
| Requirement | Specification | Source |
|
||||
|-------------|--------------|--------|
|
||||
| Minimum test coverage | ≥ 80% line/branch coverage for `SlpModularCms.Modules.Master` (excluding items below) | NFR-MASTER-05 |
|
||||
| Coverage exclusions | Apply `[ExcludeFromCodeCoverage]` to: `MasterModule.cs` (IModule boilerplate), EF Core migration files, plain DTO/record classes with no logic | Q4 |
|
||||
| Test project | `SlpModularCms.Modules.Master.Tests` — separate project; mirrors production project structure | Unit decomposition decision |
|
||||
| Key test targets | `CmsInstanceService`, `SlaveApiClient`, `IntegrityCheckBackgroundService`, `CmsInstanceController` | NFR-MASTER-05 |
|
||||
| Interface-driven design | `ICmsInstanceRepository`, `ICmsInstanceService`, `ISlaveApiClient` interfaces required to enable unit test mocking | derived |
|
||||
|
||||
---
|
||||
|
||||
## Maintainability
|
||||
|
||||
| Requirement | Specification | Source |
|
||||
|-------------|--------------|--------|
|
||||
| Log level — integrity check failures | **Warning** — slave unreachability during background checks is expected; does not require immediate attention | Q5 |
|
||||
| Log level — status push failures | **Error** — owner-triggered action failed to reach slave; requires visibility | Q5 |
|
||||
| Log level — re-registration on mismatch | **Information** — expected recovery action | derived |
|
||||
| Log level — background service tick | **Debug** — high frequency; only visible when debugging | derived |
|
||||
| Structured logging | Use `ILogger<T>` with structured message templates; include `slaveUrl` and `instanceId` in log scope | derived |
|
||||
|
||||
---
|
||||
|
||||
## Updated `MasterModuleOptions` Fields
|
||||
|
||||
The following field is added as a result of Q1:
|
||||
|
||||
| Property | Type | Default | Notes |
|
||||
|----------|------|---------|-------|
|
||||
| `HttpTimeoutSeconds` | `int` | `10` | Timeout applied to each individual HTTP attempt in `SlaveApiClient` |
|
||||
|
||||
Full updated options shape:
|
||||
|
||||
| Property | Type | Default | Side |
|
||||
|----------|------|---------|------|
|
||||
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master |
|
||||
| `HttpTimeoutSeconds` | `int` | `10` | Master |
|
||||
| `MasterUrl` | `string?` | `null` | Master |
|
||||
| `CacheMinutes` | `int` | `60` | Slave |
|
||||
| `ApiKey` | `string?` | `null` | Slave |
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Tech Stack Decisions — Unit 1: master-backend
|
||||
|
||||
## HTTP Client & Resilience
|
||||
|
||||
### Decision: Typed HTTP Client via `IHttpClientFactory` + Polly
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| HTTP client abstraction | `ISlaveApiClient` / `SlaveApiClient` typed client | Testable via mock injection; clean contract boundary |
|
||||
| Client registration | `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()` | Framework manages `HttpClient` lifetime and connection pooling |
|
||||
| Retry policy | **Polly** `ResiliencePipelineBuilder` with `AddRetry` | Industry standard .NET resilience library; integrates natively with `IHttpClientFactory` via `AddResilienceHandler` |
|
||||
| Retry configuration | 3 total attempts; delays: 1s → 2s (exponential); jitter optional | Bounded worst-case latency; exponential reduces thundering herd on widespread slave outages |
|
||||
| Per-attempt timeout | `MasterModuleOptions.HttpTimeoutSeconds` (default 10s) | Configurable per-environment; keeps controller responses bounded |
|
||||
|
||||
**NuGet package required**: `Microsoft.Extensions.Http.Resilience` (includes Polly integration)
|
||||
|
||||
**Registration pattern**:
|
||||
```csharp
|
||||
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
|
||||
.AddResilienceHandler("slave-retry", builder =>
|
||||
{
|
||||
builder.AddRetry(new HttpRetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = 2,
|
||||
Delay = TimeSpan.FromSeconds(1),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
});
|
||||
builder.AddTimeout(TimeSpan.FromSeconds(options.HttpTimeoutSeconds));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Protection
|
||||
|
||||
### Decision: Default ASP.NET Core Data Protection (file system)
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| Key storage | Default file system (no explicit `PersistKeysTo*` call) | Zero configuration; acceptable for single-instance; owner manages production key persistence |
|
||||
| Purpose string | `"SlpModularCms.Master.ApiKey"` | Scoped protection; prevents cross-purpose decryption |
|
||||
| Registration | `services.AddDataProtection()` (already called by framework if not explicitly called) | No extra setup needed beyond injecting `IDataProtectionProvider` |
|
||||
|
||||
**Production note** (to be included in Unit 4 README update): For containerized or multi-instance deployments, configure a persistent key ring (e.g., `PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`). Without it, restarting the container causes all encrypted `ApiKey` values to become unreadable.
|
||||
|
||||
---
|
||||
|
||||
## Background Service
|
||||
|
||||
### Decision: .NET `BackgroundService` + `PeriodicTimer`
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| Base class | `BackgroundService` | Built-in .NET hosted service; lifecycle managed by `IHostApplicationLifetime` |
|
||||
| Timer mechanism | `PeriodicTimer` | Allocates less than `Timer`; await-friendly; cancellation-aware |
|
||||
| Scope management | `IServiceScopeFactory.CreateScope()` per tick | Required because `ICmsInstanceService` is Scoped; prevents captive dependency |
|
||||
| Exception handling | `try/catch` around entire tick body; log `Error` and continue | Prevents background service crash on unexpected errors |
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
### Decision: `ILogger<T>` structured logging
|
||||
|
||||
| Scenario | Log Level | Structured Fields |
|
||||
|----------|-----------|------------------|
|
||||
| Status push failed (slave unreachable) | `Error` | `instanceId`, `slaveUrl`, `exception` |
|
||||
| Integrity check: slave unreachable | `Warning` | `instanceId`, `slaveUrl` |
|
||||
| Integrity check: URL mismatch detected | `Warning` | `instanceId`, `slaveUrl`, `expectedUrl`, `registeredUrl` |
|
||||
| Integrity check: re-registration succeeded | `Information` | `instanceId`, `slaveUrl` |
|
||||
| Integrity check: re-registration failed | `Warning` | `instanceId`, `slaveUrl` |
|
||||
| Background service tick started | `Debug` | `instanceCount` |
|
||||
| ApiKey decryption failed | `Error` | `instanceId` (do NOT log the key itself) |
|
||||
|
||||
---
|
||||
|
||||
## EF Core / Database
|
||||
|
||||
### Decision: Per-module `MasterDbContext` with own migrations
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| DbContext class | `MasterDbContext : DbContext` in `SlpModularCms.Modules.Master` |
|
||||
| Migration assembly | `SlpModularCms.Modules.Master` (same project) |
|
||||
| Migration application | `app.ApplicationServices.CreateScope()` → `MasterDbContext.Database.MigrateAsync()` in `MasterModule.UseModule(IApplicationBuilder)` |
|
||||
| Tables owned | `CmsInstances`, `DataProtectionKeys` (if needed in future) |
|
||||
| Connection string | Reuses the same connection string as `ApplicationDbContext` (from `ConnectionStrings:DefaultConnection`) |
|
||||
|
||||
---
|
||||
|
||||
## Test Framework
|
||||
|
||||
### Decision: xUnit + Moq (matching existing test projects)
|
||||
|
||||
| Aspect | Decision | Rationale |
|
||||
|--------|----------|-----------|
|
||||
| Test framework | xUnit | Matches existing `Availability.Tests` project |
|
||||
| Mocking | Moq | Matches existing test projects |
|
||||
| Coverage tool | coverlet (via `.runsettings` or `dotnet test --collect`) | Already in use in existing test projects |
|
||||
| `[ExcludeFromCodeCoverage]` targets | `MasterModule`, EF Core migration files, DTO records | Q4 decision |
|
||||
| HTTP testing | Mock `ISlaveApiClient` via Moq | Typed client interface enables clean mocking without `HttpMessageHandler` fakes |
|
||||
|
||||
---
|
||||
|
||||
## Summary of New Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `Microsoft.Extensions.Http.Resilience` | Latest stable | Polly integration for `IHttpClientFactory` retry policies |
|
||||
|
||||
All other dependencies (EF Core, ASP.NET Core, xUnit, Moq) are already present in the solution.
|
||||
Reference in New Issue
Block a user