cf3bd52f93
Two-node keep-oldest could NEVER survive a crash of the oldest/active node:
Akka.NET 1.5.62 KeepOldest.OldestDecision only lets down-if-alone rescue a
side with >= 2 members, so the 1-vs-1 survivor takes DownReachable and downs
ITSELF — proven live on the rig ('SBR took decision ... including myself')
before this change. static-quorum(1) is worse (IsTooManyMembers -> DownAll);
keep-majority just re-keys the fatal crash to the lowest address.
SplitBrainResolverStrategy gains 'auto-down' (new default): BuildHocon emits
Akka's AutoDowning provider with auto-down-unreachable-after = StableAfter.
The leader among the REACHABLE members downs the unreachable peer, so the
survivor takes over singletons and /health/active in ~25s regardless of which
node died. Accepted trade (explicit owner decision): a real network partition
runs dual-active until an operator restarts one side. keep-oldest remains
supported; DownIfAlone validation is now scoped to it.
Live drill on the rebuilt rig: active-crash TAKEOVER in 28s (victim still
down; all 7 singletons Younger->Oldest), standby-crash removal 27s with 0
routing blips; victims rejoin as standby in 2s. New real-cluster tests pin
both directions (SbrFailoverTests.AutoDown_*); TwoNodeClusterFixture gains a
strategy knob. All 16 appsettings flipped (src, docker, docker-env2, and the
gitignored wonder-app-vd03 overlay on disk — owner must sync to the host).
Docs: decision record docs/plans/2026-07-21-auto-down-availability-decision.md,
Component-ClusterInfrastructure downing section rewritten, drill + README
reworked (active mode now asserts takeover), deferred-work SBR row resolved.
88 lines
4.3 KiB
JSON
88 lines
4.3 KiB
JSON
{
|
|
"ScadaBridge": {
|
|
"_secretsClusterPosture": "Central pair KEK/store posture (production): see docs/operations/2026-07-16-secrets-clustered-master-key.md. Committed default is Source=Environment + relative SqlitePath (dev-safe); production uses a File KEK + shared store volume delivered out-of-band. Do NOT hardcode /shared paths here — the pre-host expander migrates the store at every Central boot and would break dev/test.",
|
|
"_nodeName": "Host-018: NodeName stamps SourceNode on AuditLog/Notifications/SiteCalls rows (CLAUDE.md 'Centralized Audit Log' decision) and backs IX_AuditLog_Node_Occurred. Convention: 'central-a'/'central-b' for central nodes, 'node-a'/'node-b' for site nodes. Override per-node in multi-node deployments (the docker per-node configs do this). When left at the default below, single-node dev rows are stamped with 'central-a'; an empty value normalises to a NULL SourceNode.",
|
|
"Node": {
|
|
"Role": "Central",
|
|
"NodeHostname": "localhost",
|
|
"RemotingPort": 8081,
|
|
"NodeName": "central-a"
|
|
},
|
|
"Cluster": {
|
|
"SeedNodes": [
|
|
"akka.tcp://scadabridge@localhost:8081",
|
|
"akka.tcp://scadabridge@localhost:8082"
|
|
],
|
|
"SplitBrainResolverStrategy": "auto-down",
|
|
"StableAfter": "00:00:15",
|
|
"HeartbeatInterval": "00:00:02",
|
|
"FailureDetectionThreshold": "00:00:10",
|
|
"MinNrOfMembers": 1
|
|
},
|
|
"_secrets": "Host-003: Secrets are NOT committed in this file. The ${secret:...} references below are resolved at startup by the pre-host secrets expander (before StartupValidator runs) from the encrypted secrets store (SQLite, seeded via the ZB.MOM.WW.Secrets store/CLI/UI). The KEK is supplied out-of-band via the ZB_SECRETS_MASTER_KEY environment variable and never committed; an unseeded reference fails closed (SecretNotFoundException) before any SQL/LDAP/cluster wiring. ROLLBACK: the whole-key environment override still wins — set ScadaBridge__Database__ConfigurationDb / ScadaBridge__Security__Ldap__ServiceAccountPassword / ScadaBridge__Security__JwtSigningKey and .AddEnvironmentVariables() overlays the concrete value before expansion runs, so the ${secret:...} token is never evaluated. NOTE (Task 1.4): the LDAP settings moved into the nested Security:Ldap sub-section (bound to the shared ZB.MOM.WW.Auth LdapOptions) — the service-account-password env var is ScadaBridge__Security__Ldap__ServiceAccountPassword (was ScadaBridge__Security__LdapServiceAccountPassword).",
|
|
"Database": {
|
|
"ConfigurationDb": "${secret:sql/scadabridge/configdb-connection}"
|
|
},
|
|
"Security": {
|
|
"Ldap": {
|
|
"Server": "localhost",
|
|
"Port": 3893,
|
|
"Transport": "None",
|
|
"AllowInsecure": true,
|
|
"SearchBase": "dc=zb,dc=local",
|
|
"ServiceAccountDn": "cn=admin,dc=zb,dc=local",
|
|
"ServiceAccountPassword": "${secret:ldap/scadabridge/service-account-password}"
|
|
},
|
|
"JwtSigningKey": "${secret:security/scadabridge/jwt-signing-key}",
|
|
"JwtExpiryMinutes": 15,
|
|
"IdleTimeoutMinutes": 30
|
|
},
|
|
"Communication": {
|
|
"DeploymentTimeout": "00:02:00",
|
|
"LifecycleTimeout": "00:00:30",
|
|
"QueryTimeout": "00:00:30",
|
|
"TransportHeartbeatInterval": "00:00:05",
|
|
"TransportFailureThreshold": "00:00:15",
|
|
"CentralFetchBaseUrl": "http://localhost:5000"
|
|
},
|
|
"HealthMonitoring": {
|
|
"ReportInterval": "00:00:30",
|
|
"OfflineTimeout": "00:01:00"
|
|
},
|
|
"InboundApi": {
|
|
"DefaultMethodTimeout": "00:00:30"
|
|
},
|
|
"Notification": {
|
|
"SmtpServer": "localhost",
|
|
"SmtpPort": 1025,
|
|
"AuthMode": "None",
|
|
"FromAddress": "scada-notifications@company.com"
|
|
},
|
|
"NotificationOutbox": {
|
|
"DispatchInterval": "00:00:10",
|
|
"DispatchBatchSize": 100,
|
|
"StuckAgeThreshold": "00:10:00",
|
|
"TerminalRetention": "365.00:00:00",
|
|
"PurgeInterval": "1.00:00:00",
|
|
"DeliveredKpiWindow": "00:01:00"
|
|
},
|
|
"KpiHistory": {
|
|
"SampleInterval": "00:01:00",
|
|
"RetentionDays": 90,
|
|
"PurgeInterval": "1.00:00:00",
|
|
"DefaultMaxSeriesPoints": 200
|
|
},
|
|
"Transport": {
|
|
"BundleSessionTtlMinutes": 30,
|
|
"MaxBundleSizeMb": 100,
|
|
"MaxUnlockAttemptsPerSession": 3,
|
|
"MaxUnlockAttemptsPerIpPerHour": 10,
|
|
"Pbkdf2Iterations": 600000,
|
|
"SchemaVersionMajor": 1
|
|
},
|
|
"Logging": {
|
|
"MinimumLevel": "Information"
|
|
}
|
|
}
|
|
}
|