Files
scadalink-design/docs/plans/2026-05-24-second-environment.md
Joseph Doherty e66fee0d26 docs(plans): add second environment (env2) implementation plan
11-task plan (T0-T10) covering the sibling docker-env2/ directory:
SQL setup script + mount, Traefik config, central/site appsettings,
docker-compose, lifecycle scripts, .gitignore, READMEs and cross-refs,
verification checklist, and a manual smoke test. No application code
changes -- pure deploy tooling. Most tasks (T0-T9) are independent
and parallel-ready; T5 is gated on T0 + T4; T10 gates on all of T0-T9.
2026-05-24 07:08:46 -04:00

1239 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Second Docker Environment (env2) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Stand up a sibling `docker-env2/` directory that brings up a minimal second ScadaLink cluster (2 central + 1 site × 2 nodes + Traefik) on the same machine, concurrent with the existing `docker/` stack, so Transport (#24) can be exercised end-to-end across two real environments.
**Architecture:** Five new app containers + Traefik on the shared `scadalink-net` Docker bridge. Reuses the existing `scadalink-mssql`, `scadalink-ldap`, `scadalink-smtp`, `scadalink-opcua`, `scadalink-restapi` infra containers. Env2 uses dedicated MSSQL databases `ScadaLinkConfig2` / `ScadaLinkMachineData2`. Host port pattern `91XX` (vs primary `90XX`). Single site `site-x`. No application/C# code changes — deploy tooling only.
**Tech Stack:** Bash, Docker / Docker Compose, MS SQL 2022 (`sqlcmd`), Traefik v3.4, the existing `scadalink:latest` image.
**Design source:** [docs/plans/2026-05-24-second-environment-design.md](2026-05-24-second-environment-design.md).
---
## Task Dependency Graph
```
T0 ─┐ ┐
T1 ─┤ (all independent, all │
T2 ─┤ parallelizable, all ├─► T10 (manual smoke test)
T3 ─┤ ready from the start) │
T4 ─┤ │
T6 ─┤ │
T7 ─┤ │
T8 ─┤ │
T9 ─┘ │
T0,T4 ──► T5 (lifecycle scripts) ─────────┘
```
T10 is the only task that requires all of T0T9 done. Everything else can run in parallel.
---
### Task 0: MSSQL setup-env2.sql + mount into infra/docker-compose.yml
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** T1, T2, T3, T4, T6, T7, T8, T9
**Files:**
- Create: `infra/mssql/setup-env2.sql`
- Modify: `infra/docker-compose.yml` (add one volume mount line)
**Step 1: Write `infra/mssql/setup-env2.sql`**
Idempotent script that creates the env2 logical databases and grants `db_owner` to the existing `scadalink_app` login. Mirrors the existing `setup.sql` exactly, swapping the database names. Does NOT create the login (it already exists from `setup.sql`).
```sql
-- ScadaLink env2 database setup
-- Creates env2 logical databases on an existing scadalink-mssql instance.
-- Idempotent: re-runs are no-ops. Assumes setup.sql has already run
-- (i.e. the scadalink_app login already exists).
-- Create env2 databases
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkConfig2')
CREATE DATABASE ScadaLinkConfig2;
GO
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkMachineData2')
CREATE DATABASE ScadaLinkMachineData2;
GO
-- Grant db_owner on ScadaLinkConfig2
USE ScadaLinkConfig2;
GO
IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app')
CREATE USER scadalink_app FOR LOGIN scadalink_app;
GO
ALTER ROLE db_owner ADD MEMBER scadalink_app;
GO
-- Grant db_owner on ScadaLinkMachineData2
USE ScadaLinkMachineData2;
GO
IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app')
CREATE USER scadalink_app FOR LOGIN scadalink_app;
GO
ALTER ROLE db_owner ADD MEMBER scadalink_app;
GO
```
**Step 2: Mount the script into the running MSSQL container via `infra/docker-compose.yml`**
In the `mssql` service `volumes:` list (currently three entries), add a fourth:
```yaml
- ./mssql/setup-env2.sql:/docker-entrypoint-initdb.d/setup-env2.sql:ro
```
So the full `mssql` service `volumes:` block becomes:
```yaml
volumes:
- 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
- ./mssql/setup-env2.sql:/docker-entrypoint-initdb.d/setup-env2.sql:ro
```
The MSSQL container does not auto-execute `/docker-entrypoint-initdb.d/*.sql`; the mount is for symmetry with the existing scripts and for the `docker-env2/init-db.sh` exec path (Task 5). On a fresh MSSQL volume, operators will follow the existing `infra/README.md` pattern of `docker exec ... sqlcmd -i /docker-entrypoint-initdb.d/setup-env2.sql` after `setup.sql`.
**Step 3: Sanity-check the SQL syntax**
```bash
# Just verify the file is well-formed bytes-wise. (We do NOT run the script
# against a live MSSQL here — that's part of Task 10's smoke test.)
test -s infra/mssql/setup-env2.sql && echo "OK"
grep -c 'IF NOT EXISTS' infra/mssql/setup-env2.sql # Expect: 4
```
Expected: `OK` and `4`.
**Step 4: Commit**
```bash
git add infra/mssql/setup-env2.sql infra/docker-compose.yml
git commit -m "feat(infra): add env2 database setup script + mount"
```
---
### Task 1: Traefik configuration for env2
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** T0, T2, T3, T4, T6, T7, T8, T9
**Files:**
- Create: `docker-env2/traefik/traefik.yml`
- Create: `docker-env2/traefik/dynamic.yml`
**Step 1: Write `docker-env2/traefik/traefik.yml`**
```yaml
entryPoints:
web:
address: ":80"
api:
dashboard: true
insecure: true
providers:
file:
filename: /etc/traefik/dynamic.yml
```
Identical to `docker/traefik/traefik.yml`. Traefik's host port mapping (9100 → 80, 8181 → 8080) is set in env2's `docker-compose.yml` (Task 4); the in-container ports are unchanged.
**Step 2: Write `docker-env2/traefik/dynamic.yml`**
```yaml
http:
routers:
central:
rule: "PathPrefix(`/`)"
service: central
entryPoints:
- web
services:
central:
loadBalancer:
healthCheck:
path: /health/active
interval: 5s
timeout: 3s
servers:
- url: "http://scadalink-env2-central-a:5000"
- url: "http://scadalink-env2-central-b:5000"
```
Identical to primary except for the two `servers:` URLs — they point at the env2 central container hostnames.
**Step 3: Commit**
```bash
git add docker-env2/traefik/traefik.yml docker-env2/traefik/dynamic.yml
git commit -m "feat(docker-env2): add Traefik load-balancer config"
```
---
### Task 2: Central node appsettings (a + b)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** T0, T1, T3, T4, T6, T7, T8, T9
**Files:**
- Create: `docker-env2/central-node-a/appsettings.Central.json`
- Create: `docker-env2/central-node-b/appsettings.Central.json`
These are clones of `docker/central-node-a/appsettings.Central.json` and `docker/central-node-b/appsettings.Central.json` with env2-specific overrides per the design's "Per-Node Appsettings — Key Differences" table.
**Step 1: Write `docker-env2/central-node-a/appsettings.Central.json`**
```json
{
"ScadaLink": {
"Node": {
"Role": "Central",
"NodeName": "central-a",
"NodeHostname": "scadalink-env2-central-a",
"RemotingPort": 8081
},
"Cluster": {
"SeedNodes": [
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
"akka.tcp://scadalink@scadalink-env2-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=ScadaLinkConfig2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
"MachineDataDb": "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData2;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-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30,
"RequireHttpsCookie": false
},
"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-env2@company.com"
},
"NotificationOutbox": {
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Transport": {
"SourceEnvironment": "docker-cluster-env2"
},
"Logging": {
"MinimumLevel": "Information"
}
}
}
```
**Step 2: Write `docker-env2/central-node-b/appsettings.Central.json`**
Identical to node-a except two fields:
- `Node.NodeName`: `"central-b"`
- `Node.NodeHostname`: `"scadalink-env2-central-b"`
(Everything else — cluster seed nodes, DB, security, etc. — is identical between a and b, exactly mirroring the primary's pattern.)
**Step 3: Sanity-check JSON validity**
```bash
python3 -m json.tool docker-env2/central-node-a/appsettings.Central.json > /dev/null && \
python3 -m json.tool docker-env2/central-node-b/appsettings.Central.json > /dev/null && \
echo "OK"
```
Expected: `OK`.
**Step 4: Commit**
```bash
git add docker-env2/central-node-a/appsettings.Central.json \
docker-env2/central-node-b/appsettings.Central.json
git commit -m "feat(docker-env2): add central node appsettings"
```
---
### Task 3: Site node appsettings (site-x a + b)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** T0, T1, T2, T4, T6, T7, T8, T9
**Files:**
- Create: `docker-env2/site-x-node-a/appsettings.Site.json`
- Create: `docker-env2/site-x-node-b/appsettings.Site.json`
**Step 1: Write `docker-env2/site-x-node-a/appsettings.Site.json`**
```json
{
"ScadaLink": {
"Node": {
"Role": "Site",
"NodeName": "node-a",
"NodeHostname": "scadalink-env2-site-x-a",
"SiteId": "site-x",
"RemotingPort": 8082,
"GrpcPort": 8083
},
"Cluster": {
"SeedNodes": [
"akka.tcp://scadalink@scadalink-env2-site-x-a:8082",
"akka.tcp://scadalink@scadalink-env2-site-x-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": {
"CentralContactPoints": [
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
"akka.tcp://scadalink@scadalink-env2-central-b:8081"
],
"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"
}
}
}
```
**Step 2: Write `docker-env2/site-x-node-b/appsettings.Site.json`**
Identical to node-a except:
- `Node.NodeName`: `"node-b"`
- `Node.NodeHostname`: `"scadalink-env2-site-x-b"`
**Step 3: Sanity-check JSON**
```bash
python3 -m json.tool docker-env2/site-x-node-a/appsettings.Site.json > /dev/null && \
python3 -m json.tool docker-env2/site-x-node-b/appsettings.Site.json > /dev/null && \
echo "OK"
```
Expected: `OK`.
**Step 4: Commit**
```bash
git add docker-env2/site-x-node-a/appsettings.Site.json \
docker-env2/site-x-node-b/appsettings.Site.json
git commit -m "feat(docker-env2): add site-x appsettings"
```
---
### Task 4: docker-env2/docker-compose.yml
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** T0, T1, T2, T3, T6, T7, T8, T9
**Files:**
- Create: `docker-env2/docker-compose.yml`
**Step 1: Write the file**
Five services: `central-a`, `central-b`, `site-x-a`, `site-x-b`, `traefik`. All on the external `scadalink-net` network. Host ports per the design's topology table.
```yaml
services:
central-a:
image: scadalink:latest
container_name: scadalink-env2-central-a
environment:
SCADALINK_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
- "9101:5000" # Web UI + Inbound API
- "9111:8081" # Akka remoting
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-env2-central-b
environment:
SCADALINK_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
- "9102:5000" # Web UI + Inbound API
- "9112: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-x-a:
image: scadalink:latest
container_name: scadalink-env2-site-x-a
environment:
SCADALINK_CONFIG: Site
ports:
- "9121:8082" # Akka remoting
- "9123:8083" # gRPC streaming
volumes:
- ./site-x-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./site-x-node-a/data:/app/data
- ./site-x-node-a/logs:/app/logs
networks:
- scadalink-net
restart: unless-stopped
site-x-b:
image: scadalink:latest
container_name: scadalink-env2-site-x-b
environment:
SCADALINK_CONFIG: Site
ports:
- "9122:8082" # Akka remoting
- "9124:8083" # gRPC streaming
volumes:
- ./site-x-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./site-x-node-b/data:/app/data
- ./site-x-node-b/logs:/app/logs
networks:
- scadalink-net
restart: unless-stopped
traefik:
image: traefik:v3.4
container_name: scadalink-env2-traefik
ports:
- "9100:80" # Env2 central load-balanced entrypoint
- "8181:8080" # Env2 Traefik dashboard
volumes:
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
networks:
- scadalink-net
restart: unless-stopped
networks:
scadalink-net:
external: true
```
**Step 2: Validate the compose file**
```bash
docker compose -f docker-env2/docker-compose.yml config > /dev/null && echo "OK"
```
Expected: `OK`. This validates YAML + references without starting anything.
**Step 3: Verify no port collisions with primary**
```bash
grep -hE '"\s*[0-9]+:[0-9]+"' docker/docker-compose.yml docker-env2/docker-compose.yml \
| sed -E 's/.*"\s*([0-9]+):.*/\1/' | sort | uniq -d
```
Expected: empty output (no duplicate host ports).
**Step 4: Commit**
```bash
git add docker-env2/docker-compose.yml
git commit -m "feat(docker-env2): add docker-compose for env2 cluster"
```
---
### Task 5: Lifecycle scripts (init-db.sh, deploy.sh, teardown.sh, seed-sites.sh)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** T6, T7, T8, T9
**Blocked by:** T0 (init-db.sh references `setup-env2.sql`), T4 (deploy.sh references `docker-compose.yml`)
**Files:**
- Create: `docker-env2/init-db.sh`
- Create: `docker-env2/deploy.sh`
- Create: `docker-env2/teardown.sh`
- Create: `docker-env2/seed-sites.sh`
All four files must end with `chmod +x`.
**Step 1: Write `docker-env2/init-db.sh`**
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if ! docker ps --format '{{.Names}}' | grep -q '^scadalink-mssql$'; then
echo "ERROR: scadalink-mssql is not running. Start it: cd infra && docker compose up -d" >&2
exit 1
fi
echo "Applying env2 database setup..."
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
< "$SCRIPT_DIR/../infra/mssql/setup-env2.sql"
echo "Env2 databases ready."
```
**Step 2: Write `docker-env2/deploy.sh`**
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== ScadaLink Env2 Docker Deploy ==="
# Reuse the primary build (same scadalink:latest image, same network creation)
"$SCRIPT_DIR/../docker/build.sh"
# Ensure env2 databases exist on the shared scadalink-mssql
"$SCRIPT_DIR/init-db.sh"
echo ""
echo "Deploying env2 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 (Traefik LB): http://localhost:9100"
echo " Central UI (node A): http://localhost:9101"
echo " Central UI (node B): http://localhost:9102"
echo " Health check: http://localhost:9101/health/ready"
echo " Active node check: http://localhost:9101/health/active"
echo " Traefik dashboard: http://localhost:8181"
echo ""
echo "To seed test site (first-time setup):"
echo " docker-env2/seed-sites.sh"
echo ""
echo "Logs: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f"
```
**Step 3: Write `docker-env2/teardown.sh`**
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== ScadaLink Env2 Docker Teardown ==="
echo "Stopping env2 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 "Env2 databases (ScadaLinkConfig2 / ScadaLinkMachineData2) remain on"
echo "the shared scadalink-mssql. To drop them:"
echo " docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \\"
echo " -S localhost -U sa -P 'ScadaLink_Dev1#' -C \\"
echo " -Q \"DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;\""
```
**Step 4: Write `docker-env2/seed-sites.sh`**
```bash
#!/bin/bash
set -euo pipefail
# Seed env2's single test site with Akka and gRPC addresses.
# Run after deploy.sh once the env2 central cluster is healthy.
#
# Prerequisites:
# - Infrastructure services running (infra/docker-compose up -d)
# - Env2 application containers running (docker-env2/deploy.sh)
# - Env2 central cluster healthy (curl http://localhost:9100/health/ready)
#
# Usage:
# docker-env2/seed-sites.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="dotnet run --project $PROJECT_ROOT/src/ScadaLink.CLI --"
AUTH="--username multi-role --password password"
URL="--url http://localhost:9100"
echo "=== Seeding ScadaLink Env2 Sites ==="
echo ""
echo "Creating Site-X (Env2 Site X)..."
$CLI $URL $AUTH site create \
--name "Env2 Site X" \
--identifier "site-x" \
--description "Env2 test site - two-node cluster" \
--node-a-address "akka.tcp://scadalink@scadalink-env2-site-x-a:8082" \
--node-b-address "akka.tcp://scadalink@scadalink-env2-site-x-b:8082" \
--grpc-node-a-address "http://scadalink-env2-site-x-a:8083" \
--grpc-node-b-address "http://scadalink-env2-site-x-b:8083" \
|| echo " (Site-X may already exist)"
echo ""
echo "=== Env2 site seeding complete ==="
echo ""
echo "Verify with: $CLI $URL $AUTH site list"
```
**Step 5: Mark all four executable**
```bash
chmod +x docker-env2/init-db.sh \
docker-env2/deploy.sh \
docker-env2/teardown.sh \
docker-env2/seed-sites.sh
```
**Step 6: Syntax-check all four with `bash -n`**
```bash
for f in docker-env2/init-db.sh docker-env2/deploy.sh docker-env2/teardown.sh docker-env2/seed-sites.sh; do
bash -n "$f" && echo "OK: $f"
done
```
Expected: four `OK:` lines.
**Step 7: Commit**
```bash
git add docker-env2/init-db.sh \
docker-env2/deploy.sh \
docker-env2/teardown.sh \
docker-env2/seed-sites.sh
git commit -m "feat(docker-env2): add lifecycle scripts (init-db, deploy, teardown, seed-sites)"
```
---
### Task 6: .gitignore additions for env2 data/logs
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** T0, T1, T2, T3, T4, T5, T7, T8, T9
**Files:**
- Modify: `.gitignore`
**Step 1: Verify current patterns already cover env2**
The existing `.gitignore` contains:
```
**/logs/
data/
```
`**/logs/` already matches `docker-env2/central-node-a/logs/` etc., and `data/` matches `docker-env2/site-x-node-a/data/` (relative pattern → matches any `data/` directory).
**Step 2: Add an explicit env2 stanza for clarity and audit**
Even though the existing patterns cover it, an explicit stanza self-documents intent for future readers and protects against someone narrowing the global patterns later. Add this block at the end of `.gitignore`:
```
# Docker env2 runtime data
docker-env2/*/logs/
docker-env2/*/data/
```
**Step 3: Verify the patterns work**
```bash
mkdir -p docker-env2/site-x-node-a/data docker-env2/central-node-a/logs
touch docker-env2/site-x-node-a/data/test.db docker-env2/central-node-a/logs/test.log
git check-ignore docker-env2/site-x-node-a/data/test.db docker-env2/central-node-a/logs/test.log
rm docker-env2/site-x-node-a/data/test.db docker-env2/central-node-a/logs/test.log
rmdir docker-env2/site-x-node-a/data docker-env2/central-node-a/logs 2>/dev/null || true
```
Expected: `git check-ignore` echoes both paths (i.e. both are ignored).
**Step 4: Commit**
```bash
git add .gitignore
git commit -m "chore(gitignore): add explicit docker-env2 runtime data patterns"
```
---
### Task 7: docker-env2/README.md
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** T0, T1, T2, T3, T4, T5, T6, T8, T9
**Files:**
- Create: `docker-env2/README.md`
**Step 1: Write the README**
Mirrors `docker/README.md`'s structure but documents the env2 specifics. Reuses tables and command idioms verbatim where they're the same; calls out the differences (port range, single site, shared MSSQL with separate databases).
```markdown
# ScadaLink Env2 Docker Infrastructure
A second Docker deployment of a minimal ScadaLink cluster topology, designed to run **concurrently with** the primary `docker/` stack so the Transport (#24) feature can be exercised end-to-end across two real environments.
See [`docs/plans/2026-05-24-second-environment-design.md`](../docs/plans/2026-05-24-second-environment-design.md) for the design rationale.
## Cluster Topology
```
┌───────────────────┐
│ Traefik LB :9100 │ ◄── CLI / Browser
│ Dashboard :8181 │
└────────┬──────────┘
│ routes to active node
┌──────────────────────┼──────────────────────────────┐
│ Env2 Central Cluster │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ env2-central-a │◄──►│ env2-central-b │ │
│ │ Web UI :9101 │ │ Web UI :9102 │ │
│ │ Akka :9111 │ │ Akka :9112 │ │
│ └────────┬─────────┘ └─────────────────┘ │
│ │ │
└───────────┼─────────────────────────────────────────┘
│ Akka.NET Remoting
┌────────────────────┐
│ Env2 Site-X │
│ (Env2 Site X) │
│ │
│ node-a ◄──► node-b│
│ Akka :9121 :9122 │
│ gRPC :9123 :9124 │
└────────────────────┘
```
## Port Allocation
Env2 host ports are the primary's ports + 100. Both stacks can run simultaneously.
| Node | Container Name | Host Web | Host Akka | Host gRPC | Internal |
|------|----------------|----------|-----------|-----------|----------|
| Traefik LB | `scadalink-env2-traefik` | 9100 | — | — | 80 (proxy), 8080 (dashboard host:8181) |
| Central A | `scadalink-env2-central-a` | 9101 | 9111 | — | 5000 (web), 8081 (Akka) |
| Central B | `scadalink-env2-central-b` | 9102 | 9112 | — | 5000 (web), 8081 (Akka) |
| Site-X A | `scadalink-env2-site-x-a` | — | 9121 | 9123 | 8082 (Akka), 8083 (gRPC) |
| Site-X B | `scadalink-env2-site-x-b` | — | 9122 | 9124 | 8082 (Akka), 8083 (gRPC) |
## Shared Infrastructure
Env2 attaches to the existing `scadalink-net` Docker bridge network and reuses these primary infra containers:
| Service | Container | What env2 uses it for |
|---------|-----------|-----------------------|
| MS SQL | `scadalink-mssql` | Env2-specific databases `ScadaLinkConfig2` / `ScadaLinkMachineData2` |
| LDAP | `scadalink-ldap` | Authentication (same test users) |
| SMTP | `scadalink-smtp` | Notification capture in Mailpit (env2 emails distinguishable by `FromAddress`) |
| OPC UA | `scadalink-opcua` | Simulated tags for site-x data connections |
| REST API | `scadalink-restapi` | External REST API testing |
## Commands
### First-Time Setup
```bash
# 1. Make sure primary infra is up (creates scadalink-net, scadalink-mssql, etc.)
cd infra && docker compose up -d && cd ..
# 2. Build image + create env2 databases + deploy env2 containers
bash docker-env2/deploy.sh
# 3. Seed env2's single test site (first-time only)
bash docker-env2/seed-sites.sh
```
### After Code Changes
```bash
bash docker-env2/deploy.sh
```
The Docker build is shared with the primary stack — if you've just run `docker/deploy.sh`, the env2 build hits a fully cached image.
### Running Both Stacks Concurrently
```bash
bash docker/deploy.sh # primary
bash docker-env2/deploy.sh # env2
```
Both UIs are now reachable:
- Primary: http://localhost:9000
- Env2: http://localhost:9100
### Teardown
```bash
bash docker-env2/teardown.sh
```
Containers stop, volumes (data + logs) preserved. To also drop the env2 databases:
```bash
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
-Q "DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;"
```
### CLI Access
```bash
dotnet run --project src/ScadaLink.CLI -- \
--url http://localhost:9100 \
--username multi-role --password password \
template list
```
### View Logs
```bash
docker compose -f docker-env2/docker-compose.yml logs -f
docker logs -f scadalink-env2-central-a
```
## Test Users
Same as primary (env2 shares LDAP). See `infra/glauth/config.toml` and primary `docker/README.md`.
## Transport Testing Workflow
See [`docs/plans/2026-05-24-second-environment-verification.md`](../docs/plans/2026-05-24-second-environment-verification.md) for the manual golden-path checklist.
## What's Different from Primary
- Single site (`site-x`) instead of three (`site-a/b/c`).
- Host port range 91XX vs primary 90XX.
- Container names prefixed `scadalink-env2-`.
- Databases `ScadaLinkConfig2` / `ScadaLinkMachineData2` on the **shared** `scadalink-mssql`.
- `Transport.SourceEnvironment` = `"docker-cluster-env2"` (stamped into exported bundle manifests).
- Distinct `Security.JwtSigningKey` (sessions cannot cross envs).
```
**Step 2: Sanity-check the README**
```bash
# Verify it renders as valid markdown-ish content (no broken backtick fences)
head -50 docker-env2/README.md
wc -l docker-env2/README.md
```
Expected: ~120 lines, no obviously truncated content.
**Step 3: Commit**
```bash
git add docker-env2/README.md
git commit -m "docs(docker-env2): add env2 README"
```
---
### Task 8: Cross-reference doc updates (root README + CLAUDE.md + infra/README.md)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** T0, T1, T2, T3, T4, T5, T6, T7, T9
**Files:**
- Modify: `README.md` (project root)
- Modify: `CLAUDE.md`
- Modify: `infra/README.md`
**Step 1: Update root `README.md`**
Add a "Second Environment" callout near the top of the document. Locate the `## Document Map` section (around line 28). Insert a new subsection AFTER `## Overview`'s closing and BEFORE `## Document Map`:
```markdown
## Local Test Environments
Two Docker-based cluster topologies are available for local development and testing:
- **Primary** ([`docker/`](docker/)) — Full topology (2 central + 3 sites × 2 nodes + Traefik). Default development target.
- **Env2** ([`docker-env2/`](docker-env2/)) — Minimal sibling stack (2 central + 1 site × 2 nodes + Traefik), runs concurrently with primary on host ports 91XX. Purpose: exercise the Transport (#24) bundle export/import feature against a real second environment.
Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDAP, SMTP, OPC UA, REST API).
```
**Step 2: Update `CLAUDE.md`**
In the `## Project Structure` section (starts at line 7), find the existing `docker/` bullet:
```markdown
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
```
Immediately after that bullet, insert:
```markdown
- `docker-env2/` — Minimal second cluster topology (2 central + 1 site × 2 nodes), runs concurrently with `docker/` on host ports 91XX. Built specifically for testing the Transport (#24) feature with two real environments. See [`docker-env2/README.md`](docker-env2/README.md). Rebuild + redeploy with `bash docker-env2/deploy.sh`.
```
**Step 3: Update `infra/README.md`**
Locate the "First-Time SQL Setup" section (line 22). The existing instructions cover `setup.sql` and `machinedata_seed.sql`. Add a third step right after the existing two `docker exec ... sqlcmd ... -i ...` blocks:
```markdown
For the second environment (`docker-env2/`), also apply the env2 database setup:
```bash
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
-i /docker-entrypoint-initdb.d/setup-env2.sql
```
This creates `ScadaLinkConfig2` and `ScadaLinkMachineData2` databases on the same MSSQL instance. The script is also invoked automatically by `docker-env2/deploy.sh` via `docker-env2/init-db.sh`, so manual application here is only needed if you want the databases ready before first env2 deploy.
```
**Step 4: Verify the three edits**
```bash
grep -n "docker-env2" README.md CLAUDE.md infra/README.md
```
Expected: at least one hit in each of the three files.
**Step 5: Commit**
```bash
git add README.md CLAUDE.md infra/README.md
git commit -m "docs: cross-reference docker-env2 from root README, CLAUDE.md, and infra README"
```
---
### Task 9: Verification checklist file
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** T0, T1, T2, T3, T4, T5, T6, T7, T8
**Files:**
- Create: `docs/plans/2026-05-24-second-environment-verification.md`
**Step 1: Write the checklist**
The file mirrors the structure of `docs/plans/2026-05-24-transport-manual-verification.md` (the Transport feature's existing checklist). Walks the 6-step golden path from the design's Section 5.
```markdown
# Env2 + Transport Manual Verification Checklist
**Date created:** 2026-05-24
**Companion to:** [`2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md), [`Component-Transport.md`](../requirements/Component-Transport.md)
**Goal:** Exercise the Transport (#24) bundle export/import flow against two real running environments (primary + env2).
## Prerequisites
- `infra/` services running: `cd infra && docker compose up -d`
- Primary stack running and healthy: `bash docker/deploy.sh && bash docker/seed-sites.sh`
- `scadalink-mssql` reachable (`docker ps | grep scadalink-mssql`)
## Step 0: Bring up env2
- [ ] `bash docker-env2/deploy.sh` completes without error
- [ ] `docker ps --format '{{.Names}}'` shows 5 new `scadalink-env2-*` containers
- [ ] `curl -s http://localhost:9101/health/ready` returns 200 with `"status": "Healthy"`
- [ ] `curl -s http://localhost:9100/health/active` is routed through Traefik
- [ ] http://localhost:9100 loads the env2 login page in a browser
- [ ] Login as `multi-role` / `password` succeeds
- [ ] Env2 dashboard renders, Sites page is empty
## Step 1: Seed env2's test site
- [ ] `bash docker-env2/seed-sites.sh` completes without error
- [ ] Env2 UI → Sites page shows `site-x` (Env2 Site X)
- [ ] Within ~30s, site-x health turns green
## Step 2: Prepare bundle source on primary
- [ ] Primary UI (http://localhost:9000) → Design → Templates contains at least one template (create one if empty)
- [ ] Optional: prepare at least one shared script and one external system for fuller round-trip coverage
## Step 3: Export from primary
- [ ] Primary UI → Design → **Export Bundle**
- [ ] Selection step: tick at least one template (folder + items)
- [ ] Review step: confirm selected entity counts
- [ ] Encrypt step: set passphrase `test-passphrase-1`
- [ ] Download step: receive a `.scadabundle` file (note exact filename)
- [ ] Verify the manifest's `SourceEnvironment` field equals `docker-cluster` (peek inside the zip; `manifest.json` is plaintext)
## Step 4: Import into env2
- [ ] Env2 UI (http://localhost:9101) → Admin → **Import Bundle**
- [ ] Upload step: select the bundle file from Step 3
- [ ] Passphrase step: enter `test-passphrase-1` → diff loads successfully
- [ ] Diff step: all rows are "Create" (env2 was empty for these entities)
- [ ] Confirm step: import succeeds; result step shows the per-row outcome
- [ ] Env2 audit log row(s) are tagged with the bundle's `BundleImportId`
## Step 5: Verify imported artifacts in env2
- [ ] Env2 UI → Design → Templates shows the imported template(s)
- [ ] Env2 UI → Audit → Configuration Audit Log filtered by `BundleImportId` shows the import events
- [ ] (Optional) Deploy an imported template to env2's `site-x` to prove runtime validity
## Step 6: Conflict-resolution probe
- [ ] Re-upload the same `.scadabundle` to env2 with the same passphrase
- [ ] Diff step: all rows are now "Update"
- [ ] Pick mixed Skip / Overwrite / Rename per row; confirm
- [ ] Verify the chosen resolutions land correctly (Skip rows unchanged, Overwrite rows match the bundle, Rename rows present under the new name)
## Negative tests
- [ ] Wrong passphrase: try `wrong-passphrase` → friendly error, no diff loaded
- [ ] Wrong passphrase 3 times in one session → session lockout enforced (`MaxUnlockAttemptsPerSession`)
- [ ] Bump primary's `Transport.SchemaVersionMajor` to 99, export, attempt env2 import → schema-version mismatch error
- [ ] Tamper with `manifest.json` content hash (unzip → edit → re-zip) → content-hash mismatch error
## Round-trip parity
- [ ] Export the same templates from env2 (with `Transport.SourceEnvironment` = `docker-cluster-env2`)
- [ ] Re-import into primary with Skip-on-conflict for all rows
- [ ] Primary's audit log shows no actual mutations
- [ ] Primary's template revision hashes are unchanged after the no-op import
## Cleanup (optional)
- [ ] `bash docker-env2/teardown.sh` stops env2 containers
- [ ] Drop env2 databases if needed:
```bash
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
-Q "DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;"
```
## Pass criteria
All checkboxes ticked, no defects found, both stacks remain healthy after the run.
```
**Step 2: Commit**
```bash
git add docs/plans/2026-05-24-second-environment-verification.md
git commit -m "docs(plans): add env2 + Transport manual verification checklist"
```
---
### Task 10: First-deploy smoke test (manual)
**Classification:** standard
**Estimated implement time:** ~5 min (subagent runs shell commands and reports)
**Parallelizable with:** none
**Blocked by:** T0, T1, T2, T3, T4, T5, T6, T7, T8, T9
This is the live verification that the whole stack actually comes up. The subagent runs the commands and reports findings. No code changes; one commit at the end to mark the verification complete in the plan's task tracker, OR no commit at all if there are no edits.
**Files:**
- (none — verification only)
**Step 1: Confirm infra is up**
```bash
docker ps --format '{{.Names}}' | grep -E 'scadalink-(mssql|ldap|smtp|opcua|restapi)' | sort
```
Expected: 5 (or 6 with opcua2) container names.
If not, run:
```bash
cd infra && docker compose up -d && cd ..
```
**Step 2: Bring up env2**
```bash
bash docker-env2/deploy.sh
```
Expected: 5 new containers up, output ends with the "Access points" block.
**Step 3: Verify env2 container set**
```bash
docker ps --format '{{.Names}}\t{{.Status}}' | grep '^scadalink-env2-'
```
Expected: 5 lines, all `Up …`.
**Step 4: Health endpoints**
Wait ~30s for both central nodes to finish startup + migrations, then:
```bash
curl -s http://localhost:9101/health/ready | python3 -m json.tool
curl -s http://localhost:9102/health/ready | python3 -m json.tool
curl -s http://localhost:9100/health/active
```
Expected: both `/health/ready` responses are JSON with `status` Healthy; `/health/active` returns 200.
**Step 5: Verify env2 databases exist**
```bash
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -h -1 \
-Q "SELECT name FROM sys.databases WHERE name LIKE 'ScadaLink%' ORDER BY name;"
```
Expected: four database names listed — `ScadaLinkConfig`, `ScadaLinkConfig2`, `ScadaLinkMachineData`, `ScadaLinkMachineData2`.
**Step 6: Verify EF migrations ran on env2's config DB**
```bash
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -h -1 \
-d ScadaLinkConfig2 \
-Q "SELECT COUNT(*) FROM __EFMigrationsHistory;"
```
Expected: a non-zero integer (matches the count in primary's `ScadaLinkConfig`).
**Step 7: Seed env2's site**
```bash
bash docker-env2/seed-sites.sh
```
Expected: site-x created (or "may already exist" on re-run).
**Step 8: Verify site cluster reaches healthy state**
Wait ~30s after seeding, then:
```bash
docker logs scadalink-env2-site-x-a 2>&1 | tail -20
docker logs scadalink-env2-central-a 2>&1 | grep -i "site-x" | tail -5
```
Expected: no fatal errors; central log shows site-x connection / health report ingest.
**Step 9: Verify port-free coexistence with primary**
```bash
docker ps --format '{{.Names}}' | grep -c '^scadalink-'
```
Expected: 14 (8 primary app + 1 primary Traefik + 5 env2). If primary isn't running, expect 6.
**Step 10: Confirm cross-environment isolation**
```bash
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -h -1 \
-d ScadaLinkConfig \
-Q "SELECT COUNT(*) FROM Sites;"
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -h -1 \
-d ScadaLinkConfig2 \
-Q "SELECT COUNT(*) FROM Sites;"
```
Expected: primary returns 3 (site-a/b/c), env2 returns 1 (site-x). Demonstrates fully separate config DBs.
**Step 11: Report**
Subagent reports a green/red status against each of the 10 checks above. No code changes, no commit. If any step fails, the subagent attaches the failing output to the report and the controller decides whether to dispatch a fix.
---
## Acceptance Criteria for the Whole Plan
- [ ] All 10 tasks complete with passing reviews where applicable.
- [ ] `bash docker-env2/deploy.sh && bash docker-env2/seed-sites.sh` brings the env2 stack to healthy state on a developer machine where primary is already running.
- [ ] `git log --oneline` shows 9 commits (T0T9) on the feature branch; T10 commits nothing by design.
- [ ] All four primary `docker/*.sh` scripts still work (env2 work touched only `infra/docker-compose.yml``mssql.volumes:` block; no edits to anything else in `docker/`).
- [ ] [`docs/plans/2026-05-24-second-environment-verification.md`](2026-05-24-second-environment-verification.md) is exercised end-to-end against the running stacks (separate manual session — out of this plan's automated scope).