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

41 KiB
Raw Blame History

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.


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).

-- 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:

      - ./mssql/setup-env2.sql:/docker-entrypoint-initdb.d/setup-env2.sql:ro

So the full mssql service volumes: block becomes:

    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

# 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

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

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

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

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

{
  "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

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

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

{
  "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

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

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.

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

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

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

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

#!/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

#!/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

#!/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

#!/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

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

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

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

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

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).

# 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 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 docker/deploy.sh         # primary
bash docker-env2/deploy.sh    # env2

Both UIs are now reachable:

Teardown

bash docker-env2/teardown.sh

Containers stop, volumes (data + logs) preserved. To also drop the env2 databases:

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

dotnet run --project src/ScadaLink.CLI -- \
    --url http://localhost:9100 \
    --username multi-role --password password \
    template list

View Logs

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 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

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:

## 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:

- `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:

- `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:

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

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.

# 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

docker ps --format '{{.Names}}' | grep -E 'scadalink-(mssql|ldap|smtp|opcua|restapi)' | sort

Expected: 5 (or 6 with opcua2) container names.

If not, run:

cd infra && docker compose up -d && cd ..

Step 2: Bring up env2

bash docker-env2/deploy.sh

Expected: 5 new containers up, output ends with the "Access points" block.

Step 3: Verify env2 container set

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:

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

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

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 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:

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

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

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.ymlmssql.volumes: block; no edits to anything else in docker/).
  • docs/plans/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).