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>
236 lines
10 KiB
Markdown
236 lines
10 KiB
Markdown
# Services — Master CMS Module
|
|
|
|
> ⚠️ **Superseded, found stale 2026-07-04**: this document is the *inception-stage* design and describes an earlier pull-based `IMasterAvailabilityService` (static cache + `MasterModuleOptions.CacheMinutes` + `/api/internal/master/*` routes) that was **not what got built**. Construction pivoted to a push-based protocol instead (`/api/v1/master/*` routes, `_masterIsAvailable`/`_masterDisableMessage` fields, no `CacheMinutes`). A slave-pull mechanism was eventually added too, but on 2026-07-04 and with a different shape (`MasterStatusPollingBackgroundService` polling `GET /api/v1/SlaveStatus` on an interval, with a time-based fail-open) than what's described below. **Treat this file as historical intent, not current truth** — the accurate, as-built design lives in `construction/master-backend/functional-design/*.md` and `construction/slave-availability-extension/functional-design/*.md`. This divergence predates 2026-07-04 and was found (not caused) during today's documentation audit; `CacheMinutes`/`ApiKey` dead-config fallout is already tracked as TD-001 in `tech-debt-backlog`.
|
|
|
|
## Service Definitions
|
|
|
|
### Master-Side Services
|
|
|
|
#### CmsInstanceService
|
|
- **Interface**: `ICmsInstanceService`
|
|
- **Lifetime**: Scoped
|
|
- **Injected Dependencies**: `ICmsInstanceRepository`, `ISlaveApiClient`, `IOptions<MasterModuleOptions>`, `ILogger<CmsInstanceService>`
|
|
- **Responsibilities**: Business orchestration — coordinates repository (data) and SlaveApiClient (HTTP side-effects); enforces business rules (mandatory DisableMessage, Inactive cannot be pushed)
|
|
- **Key orchestration**: On `AddAsync` → persist entity then call `RegisterMasterAsync`; on `UpdateStatusAsync` → validate, persist, then call `PushStatusAsync`; on `VerifyIntegrityAsync` → query all active instances, call `GetRegisteredMasterUrlAsync` per instance, re-register on mismatch
|
|
|
|
#### SlaveApiClient
|
|
- **Interface**: `ISlaveApiClient`
|
|
- **Lifetime**: Transient (managed by `IHttpClientFactory`)
|
|
- **Registration**: `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()`
|
|
- **Injected Dependencies**: `HttpClient` (injected by framework)
|
|
- **Responsibilities**: Typed HTTP client for all Master → Slave API calls; sets `X-Master-Api-Key` header per call; deserializes responses; returns success flags rather than throwing (callers decide error handling)
|
|
- **Endpoints called**:
|
|
- `POST {slaveUrl}/api/internal/master/register`
|
|
- `PUT {slaveUrl}/api/v1/Availability/admin/status` (reuses existing slave endpoint)
|
|
- `GET {slaveUrl}/api/internal/master/registration` (integrity check)
|
|
|
|
#### IntegrityCheckBackgroundService
|
|
- **Lifetime**: Singleton (registered via `services.AddHostedService<IntegrityCheckBackgroundService>()`)
|
|
- **Injected Dependencies**: `IServiceScopeFactory`, `IOptions<MasterModuleOptions>`, `ILogger<IntegrityCheckBackgroundService>`
|
|
- **Responsibilities**: Periodic background loop; resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (required because `ICmsInstanceService` is Scoped); runs `VerifyIntegrityAsync()`; interval from `MasterModuleOptions.IntegrityCheckIntervalMinutes`
|
|
|
|
---
|
|
|
|
### Slave-Side Services
|
|
|
|
#### MasterAvailabilityService
|
|
- **Interface**: `IMasterAvailabilityService`
|
|
- **Lifetime**: Scoped
|
|
- **Injected Dependencies**: `AvailabilityDbContext`, `IHttpClientFactory`, `IOptions<MasterModuleOptions>`, `ILogger<MasterAvailabilityService>`
|
|
- **Responsibilities**: Checks DB for `MasterRegistration`; if none exists returns `HasMaster = false`; if exists checks cache freshness against `MasterModuleOptions.CacheMinutes`; pulls from Master via HTTP on cache miss; returns cached value on HTTP failure (fail-open, NFR-MASTER-01)
|
|
- **Cache fields** (static): `_cachedStatus` (default `Available`), `_cachedDisableMessage`, `_lastFetchedAt`, `_cachedMasterUrl`
|
|
- **Exemption**: No special logic needed — a Master CMS instance never calls `RegisterMaster` on itself, so `MasterRegistrations` table is empty → `HasMaster = false` always on a Master instance (FR-MASTER-09)
|
|
|
|
---
|
|
|
|
## Orchestration Flows
|
|
|
|
### Flow 1 — Add Slave CMS
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
box rgba(99,179,237,0.3) Frontend
|
|
participant UI as Browser
|
|
end
|
|
box rgba(154,230,180,0.3) Master CMS
|
|
participant Ctrl as CmsInstanceController
|
|
participant Svc as CmsInstanceService
|
|
participant Repo as CmsInstanceRepository
|
|
participant Client as SlaveApiClient
|
|
end
|
|
box rgba(246,224,94,0.3) Slave CMS
|
|
participant SlaveEndpoint as AvailabilityController
|
|
end
|
|
|
|
UI->>Ctrl: POST /api/v1/CmsInstances
|
|
Ctrl->>Svc: AddAsync(request)
|
|
Svc->>Repo: AddAsync(entity)
|
|
Repo-->>Svc: entity tracked
|
|
Svc->>Repo: SaveChangesAsync()
|
|
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
|
|
Client->>SlaveEndpoint: POST /api/internal/master/register
|
|
SlaveEndpoint-->>Client: 200 OK
|
|
Client-->>Svc: true
|
|
Svc->>Repo: UpdateAsync (LastContactedAt)
|
|
Repo-->>Svc: updated
|
|
Svc->>Repo: SaveChangesAsync()
|
|
Svc-->>Ctrl: CmsInstanceDto
|
|
Ctrl-->>UI: 201 Created
|
|
```
|
|
|
|
Text alternative: Browser posts new slave to master controller → service persists → calls slave registration endpoint → updates LastContactedAt → returns DTO.
|
|
|
|
---
|
|
|
|
### Flow 2 — Set Slave Status
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
box rgba(99,179,237,0.3) Frontend
|
|
participant UI as Browser
|
|
end
|
|
box rgba(154,230,180,0.3) Master CMS
|
|
participant Ctrl as CmsInstanceController
|
|
participant Svc as CmsInstanceService
|
|
participant Repo as CmsInstanceRepository
|
|
participant Client as SlaveApiClient
|
|
end
|
|
box rgba(246,224,94,0.3) Slave CMS
|
|
participant SlaveCtrl as AvailabilityController
|
|
end
|
|
|
|
UI->>Ctrl: PUT /api/v1/CmsInstances/{id}/status
|
|
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
|
|
Svc->>Repo: GetByIdAsync(id)
|
|
Repo-->>Svc: CmsInstance
|
|
Note over Svc: Validate DisableMessage required if NotAvailable
|
|
Svc->>Repo: UpdateAsync (Status, DisableMessage)
|
|
Svc->>Repo: SaveChangesAsync()
|
|
Svc->>Client: PushStatusAsync(url, apiKey, status, disableMessage)
|
|
Client->>SlaveCtrl: PUT /api/v1/Availability/admin/status
|
|
SlaveCtrl-->>Client: 200 OK
|
|
Client-->>Svc: true
|
|
Svc->>Repo: UpdateAsync (LastStatusPushedAt)
|
|
Svc->>Repo: SaveChangesAsync()
|
|
Svc-->>Ctrl: void
|
|
Ctrl-->>UI: 200 OK
|
|
```
|
|
|
|
Text alternative: Browser sends status update → master validates, persists, pushes to slave endpoint → updates LastStatusPushedAt → returns 200.
|
|
|
|
---
|
|
|
|
### Flow 3 — Integrity Check (Background)
|
|
|
|
```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) Master CMS
|
|
participant Svc as CmsInstanceService
|
|
participant Repo as CmsInstanceRepository
|
|
participant Client as SlaveApiClient
|
|
end
|
|
box rgba(246,224,94,0.3) Slave CMS
|
|
participant SlaveEndpoint as Slave API
|
|
end
|
|
|
|
Timer->>BgSvc: Tick (every IntegrityCheckIntervalMinutes)
|
|
BgSvc->>Svc: VerifyIntegrityAsync()
|
|
Svc->>Repo: GetActiveAsync()
|
|
Repo-->>Svc: list of active CmsInstances
|
|
loop for each active instance
|
|
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, apiKey)
|
|
Client->>SlaveEndpoint: GET /api/internal/master/registration
|
|
SlaveEndpoint-->>Client: registeredMasterUrl
|
|
Client-->>Svc: registeredMasterUrl
|
|
alt URL mismatch
|
|
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
|
|
Client->>SlaveEndpoint: POST /api/internal/master/register
|
|
SlaveEndpoint-->>Client: 200 OK
|
|
Client-->>Svc: true
|
|
end
|
|
end
|
|
Svc-->>BgSvc: done
|
|
```
|
|
|
|
Text alternative: Background timer triggers integrity service → per active slave checks registered URL → re-registers if mismatch.
|
|
|
|
---
|
|
|
|
### Flow 4 — Two-Phase Availability Gate (Slave Middleware)
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
Start(["Incoming HTTP Request"])
|
|
Bypass{"Bypass prefix?"}
|
|
HasMaster{"MasterRegistration\nexists in DB?"}
|
|
CacheFresh{"Cache fresh?"}
|
|
PullMaster["Pull status from Master\n(HTTP GET)"]
|
|
PullOk{"Pull successful?"}
|
|
MasterStatus{"Master status?"}
|
|
LocalCheck["Existing local gate\nIAvailabilityService.IsAvailableAsync()"]
|
|
AdminBypass{"Admin bypass\n(Owner/Admin JWT)?"}
|
|
Block503Master["503 Service Unavailable\n+ DisableMessage"]
|
|
Block503Local["503 / Maintenance"]
|
|
Pass(["Pass request to next middleware"])
|
|
|
|
Start --> Bypass
|
|
Bypass -->|yes| Pass
|
|
Bypass -->|no| HasMaster
|
|
HasMaster -->|no| LocalCheck
|
|
HasMaster -->|yes| CacheFresh
|
|
CacheFresh -->|yes| MasterStatus
|
|
CacheFresh -->|no| PullMaster
|
|
PullMaster --> PullOk
|
|
PullOk -->|yes| MasterStatus
|
|
PullOk -->|no - use cached| MasterStatus
|
|
MasterStatus -->|NotAvailable| Block503Master
|
|
MasterStatus -->|Available| LocalCheck
|
|
LocalCheck -->|Available| Pass
|
|
LocalCheck -->|NotAvailable or Maintenance| AdminBypass
|
|
AdminBypass -->|yes| Pass
|
|
AdminBypass -->|no| Block503Local
|
|
|
|
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 block fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
|
|
class Bypass,HasMaster,CacheFresh,PullOk,MasterStatus,AdminBypass decision
|
|
class PullMaster,LocalCheck action
|
|
class Start terminal
|
|
class Pass terminal
|
|
class Block503Master,Block503Local block
|
|
```
|
|
|
|
Text alternative: Request enters middleware → check bypass → if master registered check cached/pulled status → if NotAvailable return 503 with DisableMessage → else proceed to existing local availability gate.
|
|
|
|
---
|
|
|
|
### Flow 5 — Slave Registration (Master Registers Itself)
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
box rgba(154,230,180,0.3) Master CMS
|
|
participant Client as SlaveApiClient
|
|
end
|
|
box rgba(246,224,94,0.3) Slave CMS
|
|
participant Ctrl as AvailabilityController
|
|
participant DB as AvailabilityDbContext
|
|
end
|
|
|
|
Client->>Ctrl: POST /api/internal/master/register\nHeader: X-Master-Api-Key\nBody: {masterUrl}
|
|
Ctrl->>Ctrl: Validate API key vs MasterModuleOptions.ApiKey
|
|
alt Invalid key
|
|
Ctrl-->>Client: 401 Unauthorized
|
|
else Valid key
|
|
Ctrl->>DB: Upsert MasterRegistration (masterUrl)
|
|
DB-->>Ctrl: saved
|
|
Ctrl-->>Client: 200 OK
|
|
end
|
|
```
|
|
|
|
Text alternative: Master posts registration with API key header → slave validates key → upserts MasterRegistration record → returns 200.
|