From eb8d5ca2c0bb1b902a3b358c76fc5652adc82c00 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 22:12:50 -0400 Subject: [PATCH] feat: add Docker infrastructure for 8-node cluster topology (2 central + 3 sites) Multi-stage Dockerfile with NuGet restore layer caching, per-node appsettings with Docker hostnames, shared bridge network with infra services, and build/deploy/teardown scripts. Ports use 90xx block to avoid conflicts. --- .dockerignore | 19 ++ docker/Dockerfile | 46 +++ docker/README.md | 273 ++++++++++++++++++ docker/build.sh | 22 ++ .../central-node-a/appsettings.Central.json | 59 ++++ .../central-node-b/appsettings.Central.json | 59 ++++ docker/deploy.sh | 27 ++ docker/docker-compose.yml | 126 ++++++++ docker/site-a-node-a/appsettings.Site.json | 54 ++++ docker/site-a-node-b/appsettings.Site.json | 54 ++++ docker/site-b-node-a/appsettings.Site.json | 54 ++++ docker/site-b-node-b/appsettings.Site.json | 54 ++++ docker/site-c-node-a/appsettings.Site.json | 54 ++++ docker/site-c-node-b/appsettings.Site.json | 54 ++++ docker/teardown.sh | 17 ++ infra/docker-compose.yml | 15 + 16 files changed, 987 insertions(+) create mode 100644 .dockerignore create mode 100644 docker/Dockerfile create mode 100644 docker/README.md create mode 100755 docker/build.sh create mode 100644 docker/central-node-a/appsettings.Central.json create mode 100644 docker/central-node-b/appsettings.Central.json create mode 100755 docker/deploy.sh create mode 100644 docker/docker-compose.yml create mode 100644 docker/site-a-node-a/appsettings.Site.json create mode 100644 docker/site-a-node-b/appsettings.Site.json create mode 100644 docker/site-b-node-a/appsettings.Site.json create mode 100644 docker/site-b-node-b/appsettings.Site.json create mode 100644 docker/site-c-node-a/appsettings.Site.json create mode 100644 docker/site-c-node-b/appsettings.Site.json create mode 100755 docker/teardown.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..024d6a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +**/bin/ +**/obj/ +**/logs/ +**/data/ +**/.DS_Store +.git/ +.vs/ +.idea/ +docker/ +infra/ +tests/ +docs/ +AkkaDotNet/ +*.md +site_events.db +TestResults/ +*.trx +__pycache__/ +*.pyc diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c88ca9c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,46 @@ +# Stage 1: Restore (cached when .csproj/.slnx files don't change) +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore +WORKDIR /src + +# Copy all .csproj files first (rarely change) for restore layer caching +COPY src/ScadaLink.Commons/ScadaLink.Commons.csproj src/ScadaLink.Commons/ +COPY src/ScadaLink.Host/ScadaLink.Host.csproj src/ScadaLink.Host/ +COPY src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj src/ScadaLink.TemplateEngine/ +COPY src/ScadaLink.DeploymentManager/ScadaLink.DeploymentManager.csproj src/ScadaLink.DeploymentManager/ +COPY src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj src/ScadaLink.SiteRuntime/ +COPY src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj src/ScadaLink.DataConnectionLayer/ +COPY src/ScadaLink.Communication/ScadaLink.Communication.csproj src/ScadaLink.Communication/ +COPY src/ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj src/ScadaLink.StoreAndForward/ +COPY src/ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj src/ScadaLink.ExternalSystemGateway/ +COPY src/ScadaLink.NotificationService/ScadaLink.NotificationService.csproj src/ScadaLink.NotificationService/ +COPY src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj src/ScadaLink.CentralUI/ +COPY src/ScadaLink.Security/ScadaLink.Security.csproj src/ScadaLink.Security/ +COPY src/ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj src/ScadaLink.HealthMonitoring/ +COPY src/ScadaLink.SiteEventLogging/ScadaLink.SiteEventLogging.csproj src/ScadaLink.SiteEventLogging/ +COPY src/ScadaLink.ClusterInfrastructure/ScadaLink.ClusterInfrastructure.csproj src/ScadaLink.ClusterInfrastructure/ +COPY src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj src/ScadaLink.InboundAPI/ +COPY src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj src/ScadaLink.ConfigurationDatabase/ +COPY src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj src/ScadaLink.ManagementService/ + +# Restore NuGet packages via Host project (follows ProjectReferences to all 17 dependencies) +# This layer is cached until any .csproj changes — source-only changes skip restore entirely +RUN dotnet restore src/ScadaLink.Host/ScadaLink.Host.csproj + +# Stage 2: Build + Publish +FROM restore AS build +COPY src/ src/ +RUN dotnet publish src/ScadaLink.Host/ScadaLink.Host.csproj \ + -c Release -o /app/publish --no-restore + +# Stage 3: Runtime (minimal image, no SDK) +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Create data directory for SQLite databases (site nodes) +RUN mkdir -p /app/data /app/logs + +COPY --from=build /app/publish . + +EXPOSE 5000 8081 8082 + +ENTRYPOINT ["dotnet", "ScadaLink.Host.dll"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..a78a25a --- /dev/null +++ b/docker/README.md @@ -0,0 +1,273 @@ +# ScadaLink Docker Infrastructure + +Local Docker deployment of the full ScadaLink cluster topology: a 2-node central cluster and three 2-node site clusters. + +## Cluster Topology + +``` +┌─────────────────────────────────────────────────────┐ +│ Central Cluster │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ central-node-a │◄──►│ central-node-b │ │ +│ │ (leader/oldest) │ │ (standby) │ │ +│ │ Web UI :9001 │ │ Web UI :9002 │ │ +│ │ Akka :9011 │ │ Akka :9012 │ │ +│ └────────┬─────────┘ └─────────────────┘ │ +│ │ │ +└───────────┼─────────────────────────────────────────┘ + │ Akka.NET Remoting (hub-and-spoke) + ├──────────────────┬──────────────────┐ + ▼ ▼ ▼ +┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ +│ Site-A Cluster │ │ Site-B Cluster │ │ Site-C Cluster │ +│ (Test Plant A) │ │ (Test Plant B) │ │ (Test Plant C) │ +│ │ │ │ │ │ +│ node-a ◄──► node-b│ │ node-a ◄──► node-b│ │ node-a ◄──► node-b│ +│ :9021 :9022 │ │ :9031 :9032 │ │ :9041 :9042 │ +└────────────────────┘ └────────────────────┘ └────────────────────┘ +``` + +### Central Cluster (active/standby) + +Runs the web UI (Blazor Server), Template Engine, Deployment Manager, Security, Inbound API, Management Service, and Health Monitoring. Connects to MS SQL for configuration and machine data, LDAP for authentication, and SMTP for notifications. + +### Site Clusters (active/standby each) + +Each site cluster runs Site Runtime, Data Connection Layer, Store-and-Forward, and Site Event Logging. Sites connect to OPC UA for device data and to the central cluster via Akka.NET remoting. Deployed configurations and S&F buffers are stored in local SQLite databases per node. + +| Site Cluster | Site Identifier | Central UI Name | +|-------------|-----------------|-----------------| +| Site-A | `site-a` | Test Plant A | +| Site-B | `site-b` | Test Plant B | +| Site-C | `site-c` | Test Plant C | + +## Port Allocation + +### Application Nodes + +| Node | Container Name | Host Web Port | Host Akka Port | Internal Ports | +|------|---------------|---------------|----------------|----------------| +| Central A | `scadalink-central-a` | 9001 | 9011 | 5000 (web), 8081 (Akka) | +| Central B | `scadalink-central-b` | 9002 | 9012 | 5000 (web), 8081 (Akka) | +| Site-A A | `scadalink-site-a-a` | — | 9021 | 8082 (Akka) | +| Site-A B | `scadalink-site-a-b` | — | 9022 | 8082 (Akka) | +| Site-B A | `scadalink-site-b-a` | — | 9031 | 8082 (Akka) | +| Site-B B | `scadalink-site-b-b` | — | 9032 | 8082 (Akka) | +| Site-C A | `scadalink-site-c-a` | — | 9041 | 8082 (Akka) | +| Site-C B | `scadalink-site-c-b` | — | 9042 | 8082 (Akka) | + +Port block pattern: `90X1`/`90X2` where X = 0 (central), 1 (web), 2 (site-a), 3 (site-b), 4 (site-c). + +### Infrastructure Services (from `infra/docker-compose.yml`) + +| Service | Container Name | Host Port | Purpose | +|---------|---------------|-----------|---------| +| MS SQL 2022 | `scadalink-mssql` | 1433 | Configuration and machine data databases | +| LDAP (GLAuth) | `scadalink-ldap` | 3893 | Authentication with test users | +| SMTP (Mailpit) | `scadalink-smtp` | 1025 / 8025 | Email capture (SMTP / web UI) | +| OPC UA | `scadalink-opcua` | 50000 / 8080 | Simulated OPC UA server (protocol / web UI) | +| REST API | `scadalink-restapi` | 5200 | External REST API for integration testing | + +All containers communicate over the shared `scadalink-net` Docker bridge network using container names as hostnames. + +## Directory Structure + +``` +docker/ +├── Dockerfile # Multi-stage build (shared by all nodes) +├── docker-compose.yml # 8-node application stack +├── build.sh # Build Docker image +├── deploy.sh # Build + deploy all containers +├── teardown.sh # Stop and remove containers +├── central-node-a/ +│ ├── appsettings.Central.json # Central node A configuration +│ └── logs/ # Serilog file output (gitignored) +├── central-node-b/ +│ ├── appsettings.Central.json +│ └── logs/ +├── site-a-node-a/ +│ ├── appsettings.Site.json # Site-A node A configuration +│ ├── data/ # SQLite databases (gitignored) +│ └── logs/ +├── site-a-node-b/ +│ ├── appsettings.Site.json +│ ├── data/ +│ └── logs/ +├── site-b-node-a/ +│ ├── appsettings.Site.json # Site-B node A configuration +│ ├── data/ +│ └── logs/ +├── site-b-node-b/ +│ ├── appsettings.Site.json +│ ├── data/ +│ └── logs/ +├── site-c-node-a/ +│ ├── appsettings.Site.json # Site-C node A configuration +│ ├── data/ +│ └── logs/ +└── site-c-node-b/ + ├── appsettings.Site.json + ├── data/ + └── logs/ +``` + +## Commands + +### Initial Setup + +Start infrastructure services first, then build and deploy the application: + +```bash +# 1. Start test infrastructure (MS SQL, LDAP, SMTP, OPC UA) +cd infra && docker compose up -d && cd .. + +# 2. Build and deploy all 8 ScadaLink nodes +docker/deploy.sh +``` + +### After Code Changes + +Rebuild and redeploy. The Docker build cache skips NuGet restore when only source files change: + +```bash +docker/deploy.sh +``` + +### Stop Application Nodes + +Stops and removes all 8 application containers. Site SQLite databases and log files are preserved in node directories: + +```bash +docker/teardown.sh +``` + +### Stop Everything + +```bash +docker/teardown.sh +cd infra && docker compose down && cd .. +``` + +### View Logs + +```bash +# All nodes (follow mode) +docker compose -f docker/docker-compose.yml logs -f + +# Single node +docker logs -f scadalink-central-a + +# Filter by site cluster +docker compose -f docker/docker-compose.yml logs -f site-a-a site-a-b +docker compose -f docker/docker-compose.yml logs -f site-b-a site-b-b +docker compose -f docker/docker-compose.yml logs -f site-c-a site-c-b + +# Persisted log files +ls docker/central-node-a/logs/ +``` + +### Restart a Single Node + +```bash +docker restart scadalink-central-a +``` + +### Check Cluster Health + +```bash +# Central node A health check +curl -s http://localhost:9001/health/ready | python3 -m json.tool + +# Central node B health check +curl -s http://localhost:9002/health/ready | python3 -m json.tool +``` + +### CLI Access + +Connect the ScadaLink CLI to the central cluster via host-mapped Akka remoting ports: + +```bash +dotnet run --project src/ScadaLink.CLI -- \ + --contact-points akka.tcp://scadalink@localhost:9011 \ + --username admin --password password \ + template list +``` + +### Clear Site Data + +Remove SQLite databases to reset site state (deployed configs, S&F buffers): + +```bash +# Single site +rm -rf docker/site-a-node-a/data docker/site-a-node-b/data +docker restart scadalink-site-a-a scadalink-site-a-b + +# All sites +rm -rf docker/site-*/data +docker restart scadalink-site-a-a scadalink-site-a-b \ + scadalink-site-b-a scadalink-site-b-b \ + scadalink-site-c-a scadalink-site-c-b +``` + +### Rebuild Image From Scratch (no cache) + +```bash +docker build --no-cache -t scadalink:latest -f docker/Dockerfile . +``` + +## Build Cache + +The Dockerfile uses a multi-stage build optimized for fast rebuilds: + +1. **Restore stage**: Copies only `.csproj` files and runs `dotnet restore`. This layer is cached as long as no project file changes. +2. **Build stage**: Copies source code and runs `dotnet publish --no-restore`. Re-runs on any source change but skips restore. +3. **Runtime stage**: Uses the slim `aspnet:10.0` base image with only the published output. + +Typical rebuild after a source-only change takes ~5 seconds (restore cached, only build + publish runs). + +## Test Users + +All test passwords are `password`. See `infra/glauth/config.toml` for the full list. + +| Username | Roles | Use Case | +|----------|-------|----------| +| `admin` | Admin | System administration | +| `designer` | Design | Template authoring | +| `deployer` | Deployment | Instance deployment (all sites) | +| `multi-role` | Admin, Design, Deployment | Full access for testing | + +## Failover Testing + +### Central Failover + +```bash +# Stop the active central node +docker stop scadalink-central-a + +# Verify central-b takes over (check logs for leader election) +docker logs -f scadalink-central-b + +# Access UI on standby node +open http://localhost:9002 + +# Restore the original node +docker start scadalink-central-a +``` + +### Site Failover + +```bash +# Stop the active site-a node +docker stop scadalink-site-a-a + +# Verify site-a-b takes over singleton (DeploymentManager) +docker logs -f scadalink-site-a-b + +# Restore +docker start scadalink-site-a-a +``` + +Same pattern applies for site-b (`scadalink-site-b-a`/`scadalink-site-b-b`) and site-c (`scadalink-site-c-a`/`scadalink-site-c-b`). + +Failover takes approximately 25 seconds (2s heartbeat + 10s detection threshold + 15s stable-after for split-brain resolver). diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 0000000..ca4e321 --- /dev/null +++ b/docker/build.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "=== ScadaLink Docker Build ===" + +# Ensure the shared network exists +if ! docker network inspect scadalink-net >/dev/null 2>&1; then + echo "Creating scadalink-net network..." + docker network create scadalink-net +fi + +# Build from repo root (so COPY paths in Dockerfile resolve correctly) +echo "Building scadalink:latest image..." +docker build \ + -t scadalink:latest \ + -f "$SCRIPT_DIR/Dockerfile" \ + "$REPO_ROOT" + +echo "Build complete: scadalink:latest" diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json new file mode 100644 index 0000000..655dd80 --- /dev/null +++ b/docker/central-node-a/appsettings.Central.json @@ -0,0 +1,59 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Central", + "NodeHostname": "scadalink-central-a", + "RemotingPort": 8081 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-central-a:8081", + "akka.tcp://scadalink@scadalink-central-b:8081" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "ConfigurationDb": "Server=scadalink-mssql,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true", + "MachineDataDb": "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true" + }, + "Security": { + "LdapServer": "scadalink-ldap", + "LdapPort": 3893, + "LdapUseTls": false, + "AllowInsecureLdap": true, + "LdapSearchBase": "dc=scadalink,dc=local", + "LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local", + "LdapServiceAccountPassword": "password", + "JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long", + "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" + }, + "HealthMonitoring": { + "ReportInterval": "00:00:30", + "OfflineTimeout": "00:01:00" + }, + "InboundApi": { + "DefaultMethodTimeout": "00:00:30" + }, + "Notification": { + "SmtpServer": "scadalink-smtp", + "SmtpPort": 1025, + "AuthMode": "None", + "FromAddress": "scada-notifications@company.com" + }, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json new file mode 100644 index 0000000..adc6f12 --- /dev/null +++ b/docker/central-node-b/appsettings.Central.json @@ -0,0 +1,59 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Central", + "NodeHostname": "scadalink-central-b", + "RemotingPort": 8081 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-central-a:8081", + "akka.tcp://scadalink@scadalink-central-b:8081" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "ConfigurationDb": "Server=scadalink-mssql,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true", + "MachineDataDb": "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true" + }, + "Security": { + "LdapServer": "scadalink-ldap", + "LdapPort": 3893, + "LdapUseTls": false, + "AllowInsecureLdap": true, + "LdapSearchBase": "dc=scadalink,dc=local", + "LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local", + "LdapServiceAccountPassword": "password", + "JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long", + "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" + }, + "HealthMonitoring": { + "ReportInterval": "00:00:30", + "OfflineTimeout": "00:01:00" + }, + "InboundApi": { + "DefaultMethodTimeout": "00:00:30" + }, + "Notification": { + "SmtpServer": "scadalink-smtp", + "SmtpPort": 1025, + "AuthMode": "None", + "FromAddress": "scada-notifications@company.com" + }, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/deploy.sh b/docker/deploy.sh new file mode 100755 index 0000000..1cbff6d --- /dev/null +++ b/docker/deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== ScadaLink Docker Deploy ===" + +# Build image (creates network if needed) +"$SCRIPT_DIR/build.sh" + +echo "" +echo "Deploying containers..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --force-recreate + +echo "" +echo "Container status:" +docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps + +echo "" +echo "Access points:" +echo " Central UI (node A): http://localhost:9001" +echo " Central UI (node B): http://localhost:9002" +echo " Health check: http://localhost:9001/health/ready" +echo " CLI contact points: akka.tcp://scadalink@localhost:9011" +echo " akka.tcp://scadalink@localhost:9012" +echo "" +echo "Logs: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9d8695f --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,126 @@ +services: + central-a: + image: scadalink:latest + container_name: scadalink-central-a + environment: + SCADALINK_CONFIG: Central + ASPNETCORE_URLS: "http://+:5000" + ports: + - "9001:5000" # Web UI + Inbound API + - "9011:8081" # Akka remoting (host access for CLI/debugging) + volumes: + - ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro + - ./central-node-a/logs:/app/logs + networks: + - scadalink-net + restart: unless-stopped + + central-b: + image: scadalink:latest + container_name: scadalink-central-b + environment: + SCADALINK_CONFIG: Central + ASPNETCORE_URLS: "http://+:5000" + ports: + - "9002:5000" # Web UI + Inbound API + - "9012:8081" # Akka remoting + volumes: + - ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro + - ./central-node-b/logs:/app/logs + networks: + - scadalink-net + restart: unless-stopped + + site-a-a: + image: scadalink:latest + container_name: scadalink-site-a-a + environment: + SCADALINK_CONFIG: Site + ports: + - "9021:8082" # Akka remoting (host access for debugging) + 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: + - scadalink-net + restart: unless-stopped + + site-a-b: + image: scadalink:latest + container_name: scadalink-site-a-b + environment: + SCADALINK_CONFIG: Site + ports: + - "9022:8082" # Akka remoting + 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: + - scadalink-net + restart: unless-stopped + + site-b-a: + image: scadalink:latest + container_name: scadalink-site-b-a + environment: + SCADALINK_CONFIG: Site + ports: + - "9031:8082" # Akka remoting + 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: + - scadalink-net + restart: unless-stopped + + site-b-b: + image: scadalink:latest + container_name: scadalink-site-b-b + environment: + SCADALINK_CONFIG: Site + ports: + - "9032:8082" # Akka remoting + 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: + - scadalink-net + restart: unless-stopped + + site-c-a: + image: scadalink:latest + container_name: scadalink-site-c-a + environment: + SCADALINK_CONFIG: Site + ports: + - "9041:8082" # Akka remoting + 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: + - scadalink-net + restart: unless-stopped + + site-c-b: + image: scadalink:latest + container_name: scadalink-site-c-b + environment: + SCADALINK_CONFIG: Site + ports: + - "9042:8082" # Akka remoting + 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: + - scadalink-net + restart: unless-stopped + +networks: + scadalink-net: + external: true diff --git a/docker/site-a-node-a/appsettings.Site.json b/docker/site-a-node-a/appsettings.Site.json new file mode 100644 index 0000000..234fbc4 --- /dev/null +++ b/docker/site-a-node-a/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-a-a", + "SiteId": "site-a", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-a-a:8082", + "akka.tcp://scadalink@scadalink-site-a-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/site-a-node-b/appsettings.Site.json b/docker/site-a-node-b/appsettings.Site.json new file mode 100644 index 0000000..d7319e6 --- /dev/null +++ b/docker/site-a-node-b/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-a-b", + "SiteId": "site-a", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-a-a:8082", + "akka.tcp://scadalink@scadalink-site-a-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/site-b-node-a/appsettings.Site.json b/docker/site-b-node-a/appsettings.Site.json new file mode 100644 index 0000000..d885a4c --- /dev/null +++ b/docker/site-b-node-a/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-b-a", + "SiteId": "site-b", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-b-a:8082", + "akka.tcp://scadalink@scadalink-site-b-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/site-b-node-b/appsettings.Site.json b/docker/site-b-node-b/appsettings.Site.json new file mode 100644 index 0000000..fc76f8d --- /dev/null +++ b/docker/site-b-node-b/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-b-b", + "SiteId": "site-b", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-b-a:8082", + "akka.tcp://scadalink@scadalink-site-b-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/site-c-node-a/appsettings.Site.json b/docker/site-c-node-a/appsettings.Site.json new file mode 100644 index 0000000..6692f73 --- /dev/null +++ b/docker/site-c-node-a/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-c-a", + "SiteId": "site-c", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-c-a:8082", + "akka.tcp://scadalink@scadalink-site-c-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/site-c-node-b/appsettings.Site.json b/docker/site-c-node-b/appsettings.Site.json new file mode 100644 index 0000000..c8ecd2b --- /dev/null +++ b/docker/site-c-node-b/appsettings.Site.json @@ -0,0 +1,54 @@ +{ + "ScadaLink": { + "Node": { + "Role": "Site", + "NodeHostname": "scadalink-site-c-b", + "SiteId": "site-c", + "RemotingPort": 8082 + }, + "Cluster": { + "SeedNodes": [ + "akka.tcp://scadalink@scadalink-site-c-a:8082", + "akka.tcp://scadalink@scadalink-site-c-b:8082" + ], + "SplitBrainResolverStrategy": "keep-oldest", + "StableAfter": "00:00:15", + "HeartbeatInterval": "00:00:02", + "FailureDetectionThreshold": "00:00:10", + "MinNrOfMembers": 1 + }, + "Database": { + "SiteDbPath": "/app/data/scadalink.db" + }, + "DataConnection": { + "ReconnectInterval": "00:00:05", + "TagResolutionRetryInterval": "00:00:10", + "WriteTimeout": "00:00:30" + }, + "StoreAndForward": { + "SqliteDbPath": "/app/data/store-and-forward.db", + "ReplicationEnabled": true + }, + "Communication": { + "CentralActorPath": "akka.tcp://scadalink@scadalink-central-a:8081/user/central-communication", + "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, + "PurgeScheduleCron": "0 2 * * *" + }, + "Notification": {}, + "Logging": { + "MinimumLevel": "Information" + } + } +} diff --git a/docker/teardown.sh b/docker/teardown.sh new file mode 100755 index 0000000..aa5c2f3 --- /dev/null +++ b/docker/teardown.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== ScadaLink Docker Teardown ===" + +echo "Stopping ScadaLink application containers..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" down + +echo "" +echo "Teardown complete." +echo "Site data (SQLite DBs) and logs are preserved in node directories." +echo "" +echo "To also remove persistent data:" +echo " rm -rf $SCRIPT_DIR/site-a-node-a/data $SCRIPT_DIR/site-a-node-b/data" +echo " rm -rf $SCRIPT_DIR/*/logs" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 52629cb..1b71e2f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -16,6 +16,8 @@ services: --gn=5 --nf=/app/config/nodes.json --pn=50000 + networks: + - scadalink-net restart: unless-stopped ldap: @@ -25,6 +27,8 @@ services: - "3893:3893" volumes: - ./glauth/config.toml:/app/config/config.cfg:ro + networks: + - scadalink-net restart: unless-stopped mssql: @@ -40,6 +44,8 @@ services: - scadalink-mssql-data:/var/opt/mssql - ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro - ./mssql/machinedata_seed.sql:/docker-entrypoint-initdb.d/machinedata_seed.sql:ro + networks: + - scadalink-net restart: unless-stopped smtp: @@ -52,6 +58,8 @@ services: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_MAX_MESSAGES: 500 + networks: + - scadalink-net restart: unless-stopped restapi: @@ -62,7 +70,14 @@ services: environment: API_NO_AUTH: 0 PORT: 5200 + networks: + - scadalink-net restart: unless-stopped volumes: scadalink-mssql-data: + +networks: + scadalink-net: + name: scadalink-net + external: true