b3021e7c5f
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
137 lines
6.5 KiB
JSON
137 lines
6.5 KiB
JSON
{
|
|
"ScadaBridge": {
|
|
"Node": {
|
|
"Role": "Site",
|
|
"NodeName": "node-a",
|
|
"NodeHostname": "scadabridge-site-a-a",
|
|
"SiteId": "site-a",
|
|
"RemotingPort": 8082,
|
|
"GrpcPort": 8083,
|
|
"MetricsPort": 8084
|
|
},
|
|
// Script file access (design 2026-09-28): the Files global reads only beneath these
|
|
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
|
|
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
|
|
"SiteRuntime": {
|
|
"ScriptFileAccess": {
|
|
"AllowedRoots": [ "/app/recipes" ]
|
|
}
|
|
},
|
|
"Cluster": {
|
|
"SeedNodes": [
|
|
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
|
"akka.tcp://scadabridge@scadabridge-site-a-b:8082"
|
|
],
|
|
"SplitBrainResolverStrategy": "auto-down",
|
|
"StableAfter": "00:00:15",
|
|
"HeartbeatInterval": "00:00:02",
|
|
"FailureDetectionThreshold": "00:00:10",
|
|
"MinNrOfMembers": 1,
|
|
"_bootstrapGuard": "Gitea #33 guard ENABLED on the docker rig (2026-08-02): deploy.sh recreates all containers simultaneously, which twice split site pairs into two 1-node clusters on 2026-08-01. Lower host:port founds self-first; the higher node TCP-probes then joins peer-first.",
|
|
"BootstrapGuard": {
|
|
"Enabled": true,
|
|
"PartnerProbeSeconds": 25,
|
|
"PartnerProbeIntervalMs": 500,
|
|
"ProbeConnectTimeoutMs": 1000
|
|
}
|
|
},
|
|
"DataConnection": {
|
|
"ReconnectInterval": "00:00:05",
|
|
"TagResolutionRetryInterval": "00:00:10",
|
|
"WriteTimeout": "00:00:30",
|
|
"SeedReadTimeout": "00:00:30"
|
|
},
|
|
"Communication": {
|
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
|
// ScadaBridge__Communication__SitePsks__<siteId> (docker-compose.yml).
|
|
// Production supplies this as ${secret:SB-GRPC-PSK-<siteId>}. Without it the
|
|
// node fails StartupValidator: the gate is fail-closed, so an unset key would
|
|
// refuse every SiteStream call while the node still looked healthy.
|
|
"GrpcPsk": "dev-grpc-psk-docker-site-a",
|
|
"CentralGrpcEndpoints": [
|
|
"http://scadabridge-central-a:8083",
|
|
"http://scadabridge-central-b:8083"
|
|
],
|
|
"DeploymentTimeout": "00:02:00",
|
|
"LifecycleTimeout": "00:00:30",
|
|
"QueryTimeout": "00:00:30",
|
|
"TransportHeartbeatInterval": "00:00:05",
|
|
"TransportFailureThreshold": "00:00:15"
|
|
},
|
|
"HealthMonitoring": {
|
|
"ReportInterval": "00:00:30",
|
|
"OfflineTimeout": "00:01:00"
|
|
},
|
|
"SiteEventLog": {
|
|
"RetentionDays": 30,
|
|
"MaxStorageMb": 1024,
|
|
"_comment": "Retention uses the configured PurgeInterval delay after each purge; no wall-clock cron schedule is supported."
|
|
},
|
|
"Notification": {},
|
|
"Logging": {
|
|
"MinimumLevel": "Information"
|
|
}
|
|
},
|
|
// Consolidated site database (LocalDb Phase 1): OperationTracking + site_events.
|
|
// On the mounted /app/data volume so it survives container recreate - unlike the
|
|
// legacy site-tracking.db / site_events.db, which defaulted to CWD-relative paths
|
|
// outside the volume and were lost on every recreate.
|
|
// Replication is opt-in and configured separately; absent = local-only.
|
|
"LocalDb": {
|
|
"Path": "/app/data/site-localdb.db",
|
|
// Site-a is the rig's REPLICATED pair; site-b and site-c deliberately stay
|
|
// unreplicated so the default-OFF posture is proven side-by-side on one rig.
|
|
//
|
|
// This node is the initiator: it dials the peer. Replication is still
|
|
// bidirectional - the passive node's writes flow back over the same stream -
|
|
// so only one side needs PeerAddress. Port 8083 is the existing site gRPC
|
|
// listener (h2c); the sync endpoint shares it, no new port.
|
|
//
|
|
// The ApiKey must be IDENTICAL on both nodes: the host's
|
|
// LocalDbSyncAuthInterceptor is fail-closed, so a mismatch (or a missing key)
|
|
// rejects every sync stream. A plain dev key here matches the rig's existing
|
|
// posture; PRODUCTION uses a "${secret:...}" reference resolved by the
|
|
// pre-host secret expander.
|
|
"Replication": {
|
|
"PeerAddress": "http://scadabridge-site-a-b:8083",
|
|
"ApiKey": "dev-site-a-localdb-sync-key",
|
|
// ---- Phase 2 sizing, from the Task 1 rig soak (not from the defaults) ----
|
|
//
|
|
// The old MaxBatchSize = 16 pin (a hand-computed byte-budget proxy) was retired
|
|
// when LocalDb 0.2.0 added MaxBatchBytes (arch-review WP3.3) - but MaxBatchSize
|
|
// is NOT purely redundant with it. MaxBatchBytes bounds the WIRE message via a
|
|
// per-message split in SyncSession.PumpLoopAsync; MaxBatchSize separately bounds
|
|
// the DB READ page in OplogStore.ReadBatchAboveAsync / SnapshotStreamer, which
|
|
// materializes the whole page into memory BEFORE the byte-budget split runs. At
|
|
// the unset 500 default, a reconnect drain of 70 KB worst-case config_json rows
|
|
// can transiently allocate ~35 MB per read even though every wire message stays
|
|
// under the 2 MB MaxBatchBytes default (arch-review adversarial finding F2). Pin
|
|
// it explicitly here to bound that transient allocation; MaxBatchBytes is left
|
|
// unset (its 2 MB default) to bound the wire message.
|
|
"MaxBatchSize": 64,
|
|
//
|
|
// Backlog caps bound the oplog while the peer is offline. Exceeding them is
|
|
// NOT data loss: the oplog is pruned to the ceiling and needs_snapshot is set,
|
|
// so the peer catches up by snapshot resync instead of incrementally. That
|
|
// makes tighter-than-default correct here - it trades a rare full resync for a
|
|
// bounded file. Sized from the soak's 0.80 sf_messages rows/sec (the only
|
|
// non-zero writer measured): ~69k rows/day, so 2 days is ~138k. 250,000 leaves
|
|
// room for burst without approaching the 1,000,000 default.
|
|
"MaxOplogRows": 250000,
|
|
"MaxOplogAge": "2.00:00:00"
|
|
}
|
|
},
|
|
// arch-review remediation WP1.2: the site hot-path audit writer's SQLite file has no
|
|
// default path (mirrors LocalDb:Path above) and StartupValidator now requires it for
|
|
// Site nodes — an unset value used to fall back to a bare "auditlog.db" resolved
|
|
// relative to CWD, i.e. the container's ephemeral overlayfs, so the pending audit
|
|
// backlog was silently discarded on every recreate. On the mounted /app/data volume,
|
|
// same as LocalDb:Path, so it survives container recreate.
|
|
"AuditLog": {
|
|
"SiteWriter": {
|
|
"DatabasePath": "/app/data/auditlog.db"
|
|
}
|
|
}
|
|
}
|