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.
1239 lines
41 KiB
Markdown
1239 lines
41 KiB
Markdown
# 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 T0–T9 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 (T0–T9) 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).
|