780bb9c369
Central now ALSO listens for the seven site→central control messages over gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet — sites keep CentralTransport=Akka (T1A.3's job); central simply starts also accepting. - CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the SAME in-process message the ClusterClient path carries, Asks the existing CentralCommunicationActor (zero handler-logic changes), encodes the reply via the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady — Unavailable until AkkaHostedService hands the actor over. Heartbeat stays fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status mapping is retry-aware: Unavailable (never dispatched, safe to cross-node retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere). - CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site (verify the Bearer token against the key for the site in the required x-scadabridge-site header, via ISitePskProvider) where a site verifies its one own-key — a genuinely different model. Fail-closed on every branch: missing or blank header, unresolvable key, and mismatched token all → PermissionDenied, never pass-through. One public constructor only (the explicit-prefix ctor is internal), pinned by a reflection test — a second public ctor makes Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate. - Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort (default 8083, symmetric with sites), mirroring the Site branch. Additive to central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with MapGrpcService. Port range-validated by NodeOptionsValidator. - Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes so a later task can exercise it. Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor + real service over a stub actor) proves auth positives/negatives are distinguishable and covers unary + the ingest bridge shapes; the interceptor is registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests (Communication.Tests, TestKit) covers the readiness gate, fire-and-forget heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active <Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
234 lines
11 KiB
YAML
234 lines
11 KiB
YAML
services:
|
|
central-a:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-central-a
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Central
|
|
ASPNETCORE_ENVIRONMENT: Development
|
|
ASPNETCORE_URLS: "http://+:5000"
|
|
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
|
|
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
|
|
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
|
|
# out-of-band (env/secret store), never from source control — see
|
|
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
|
|
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
|
|
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
|
|
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
|
|
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
|
|
# expander runs, so the dev cluster boots with no KEK/secret store. The SQL password is
|
|
# the same value already committed for the sibling scadabridge-mssql container
|
|
# (infra/docker-compose.yml MSSQL_SA_PASSWORD) — consolidation, not new exposure. Real
|
|
# deployments use ${secret:} tokens (src/.../appsettings.Central.json) + a seeded store.
|
|
ScadaBridge__Database__ConfigurationDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
|
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
|
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
|
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
|
|
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
|
|
# Central verifies/presents these; each site node carries the same value as
|
|
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
|
|
# env overrides (not in the mounted central appsettings) so that file stays free of
|
|
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
|
|
# secret store, which is also the only source that can serve a site added at runtime.
|
|
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
|
|
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
|
|
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
|
|
ports:
|
|
- "9001:5000" # Web UI + Inbound API
|
|
- "9011:8081" # Akka remoting (host access for CLI/debugging)
|
|
- "9013:8083" # gRPC control plane (CentralControlService, T1A.2)
|
|
volumes:
|
|
- ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro
|
|
- ./central-node-a/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
central-b:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-central-b
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Central
|
|
ASPNETCORE_ENVIRONMENT: Development
|
|
ASPNETCORE_URLS: "http://+:5000"
|
|
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
|
|
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
|
|
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
|
|
# out-of-band (env/secret store), never from source control — see
|
|
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
|
|
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
|
|
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
|
|
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
|
|
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
|
|
# expander runs, so the dev cluster boots with no KEK/secret store. The SQL password is
|
|
# the same value already committed for the sibling scadabridge-mssql container
|
|
# (infra/docker-compose.yml MSSQL_SA_PASSWORD) — consolidation, not new exposure. Real
|
|
# deployments use ${secret:} tokens (src/.../appsettings.Central.json) + a seeded store.
|
|
ScadaBridge__Database__ConfigurationDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
|
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
|
|
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
|
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
|
|
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
|
|
# Central verifies/presents these; each site node carries the same value as
|
|
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
|
|
# env overrides (not in the mounted central appsettings) so that file stays free of
|
|
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
|
|
# secret store, which is also the only source that can serve a site added at runtime.
|
|
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
|
|
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
|
|
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
|
|
ports:
|
|
- "9002:5000" # Web UI + Inbound API
|
|
- "9012:8081" # Akka remoting
|
|
- "9014:8083" # gRPC control plane (CentralControlService, T1A.2)
|
|
volumes:
|
|
- ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro
|
|
- ./central-node-b/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-a-a:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-a-a
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9021:8082" # Akka remoting (host access for debugging)
|
|
- "9023:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-a-node-a/data:/app/data
|
|
- ./site-a-node-a/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-a-b:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-a-b
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9022:8082" # Akka remoting
|
|
- "9024:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-a-node-b/data:/app/data
|
|
- ./site-a-node-b/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-b-a:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-b-a
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9031:8082" # Akka remoting
|
|
- "9033:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-b-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-b-node-a/data:/app/data
|
|
- ./site-b-node-a/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-b-b:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-b-b
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9032:8082" # Akka remoting
|
|
- "9034:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-b-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-b-node-b/data:/app/data
|
|
- ./site-b-node-b/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-c-a:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-c-a
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9041:8082" # Akka remoting
|
|
- "9043:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-c-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-c-node-a/data:/app/data
|
|
- ./site-c-node-a/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
site-c-b:
|
|
image: scadabridge:latest
|
|
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
|
|
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
|
|
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
|
|
stop_grace_period: 30s
|
|
container_name: scadabridge-site-c-b
|
|
environment:
|
|
SCADABRIDGE_CONFIG: Site
|
|
ports:
|
|
- "9042:8082" # Akka remoting
|
|
- "9044:8083" # gRPC streaming
|
|
volumes:
|
|
- ./site-c-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
|
- ./site-c-node-b/data:/app/data
|
|
- ./site-c-node-b/logs:/app/logs
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
traefik:
|
|
image: traefik:v3.4
|
|
container_name: scadabridge-traefik
|
|
ports:
|
|
- "9000:80" # Central load-balanced entrypoint
|
|
- "8180:8080" # Traefik dashboard
|
|
volumes:
|
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
|
- ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
networks:
|
|
- scadabridge-net
|
|
restart: unless-stopped
|
|
|
|
networks:
|
|
scadabridge-net:
|
|
external: true
|