# Second Docker Environment (`env2`) — Design

**Date:** 2026-05-24
**Status:** Approved — ready for implementation plan
**Purpose:** Stand up a second, concurrently-running ScadaBridge cluster on the same machine so the new Transport (#24) feature can be exercised end-to-end against a real second environment (export from one UI, import into the other).

## Goal

A sibling `docker-env2/` directory with `deploy.sh` / `teardown.sh` / `seed-sites.sh` / `init-db.sh` that brings up a minimal but fully-functional second cluster — its own central + site, its own ConfigurationDB — alongside the existing `docker/` stack. Both environments run concurrently and share the commodity infra services (MSSQL container, LDAP, SMTP, OPC UA, REST API). No application code changes; this is purely deploy tooling.

## Non-Goals

- Not a fully air-gapped twin (LDAP/SMTP/OPC UA/REST API are shared).
- Not a full mirror of primary's three-site topology — env2 has one site (`site-x`).
- Not a multi-tenant abstraction or `--env` flag retrofit on `docker/deploy.sh` — kept as two independent script trees for clarity.
- No new automated tests — env2 enables manual verification via [`2026-05-24-second-environment-verification.md`](2026-05-24-second-environment-verification.md) (created during implementation).

## Architecture Overview

![env2-architecture-overview](diagrams/env2-architecture-overview.png)
<!-- source: diagrams/env2-architecture-overview.drawio — edit, then re-export with export-drawio.sh -->

Both stacks attach to the same `scadabridge-net` Docker bridge so env2's app containers can reach the infra services by container hostname (`scadabridge-mssql`, `scadabridge-ldap`, etc.). Akka clusters are independent — each side's `SeedNodes` lists only its own central nodes, so they never gossip-merge despite sharing the network.

## Topology & Port Allocation

| Role           | Container name              | Host Web | Host Akka | Host gRPC | Notes |
|----------------|-----------------------------|----------|-----------|-----------|-------|
| Traefik LB     | `scadabridge-env2-traefik`    | 9100     | —         | —         | Dashboard on host 8181 |
| Central A      | `scadabridge-env2-central-a`  | 9101     | 9111      | —         | |
| Central B      | `scadabridge-env2-central-b`  | 9102     | 9112      | —         | |
| Site-X A       | `scadabridge-env2-site-x-a`   | —        | 9121      | 9123      | |
| Site-X B       | `scadabridge-env2-site-x-b`   | —        | 9122      | 9124      | |

Pattern: env2 host ports are primary + 100 (e.g. primary central-a 9001 → env2 central-a 9101). Confirmed free at design time. Identifier `site-x` distinguishes env2's single site from primary's `site-a/b/c` in logs/UI (technically not required — each central has its own ConfigurationDB — but clearer for operators).

## Infrastructure & Databases

**Shared `scadabridge-net` + shared `scadabridge-mssql` container, separate logical databases:**

- New databases: `ScadaBridgeConfig2`, `ScadaBridgeMachineData2`.
- Reuse the existing `scadabridge_app` SQL login with `db_owner` on both — one credential to manage.
- DB creation handled by a new `infra/mssql/setup-env2.sql` (idempotent, `IF NOT EXISTS`-guarded).
- Two activation paths:
  1. **Fresh MSSQL volume** — mount `setup-env2.sql` alongside the existing `setup.sql` in `/docker-entrypoint-initdb.d/` so it runs automatically on first startup.
  2. **Already-running MSSQL** — `docker-env2/init-db.sh` exec's `sqlcmd` inside the container to apply the same script. No MSSQL restart.
- EF Core migrations auto-apply on env2 central startup (matches the primary's `Development` env var pattern) → tables created on first deploy.

**Reused as-is:** LDAP (same `multi-role`/`admin`/`designer`/`deployer` test users), SMTP (Mailpit — env2's emails appear in the same inbox at http://localhost:8025, distinguishable by env2's `FromAddress`), OPC UA, REST API. All stateless commodities; isolation gains nothing by duplicating them.

Connection strings in env2 central appsettings:
```
ConfigurationDb: Server=scadabridge-mssql,1433;Database=ScadaBridgeConfig2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true
MachineDataDb:   Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData2;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true
```

## Directory Layout

```
docker-env2/
├── docker-compose.yml              # 5 services: 2 central + 2 site + Traefik
├── deploy.sh                       # build (reuses docker/build.sh) + init-db + compose up
├── teardown.sh                     # compose down (preserves data + logs)
├── seed-sites.sh                   # CLI creates site-x against http://localhost:9100
├── init-db.sh                      # sqlcmd exec against scadabridge-mssql
├── central-node-a/
│   └── appsettings.Central.json
├── central-node-b/
│   └── appsettings.Central.json
├── site-x-node-a/
│   ├── appsettings.Site.json
│   ├── data/                       # gitignored — SQLite store-and-forward + site-event-log
│   └── logs/                       # gitignored
├── site-x-node-b/
│   ├── appsettings.Site.json
│   ├── data/
│   └── logs/
└── traefik/
    ├── traefik.yml                 # dashboard on :8080 (host 8181)
    └── dynamic.yml                 # service URLs → scadabridge-env2-central-a/b
```

Mirrors `docker/`'s shape exactly so operator muscle memory carries over.

## Per-Node Appsettings — Key Differences from Primary

Each env2 appsettings file is a near-clone of the primary equivalent with these targeted overrides:

| Field                                      | Primary                                             | Env2                                                |
|--------------------------------------------|-----------------------------------------------------|-----------------------------------------------------|
| `Node.NodeHostname`                        | `scadabridge-central-a` / `scadabridge-site-a-a` / ...  | `scadabridge-env2-central-a` / `scadabridge-env2-site-x-a` / ... |
| `Cluster.SeedNodes`                        | primary central hostnames                           | env2 central hostnames                              |
| `Communication.CentralContactPoints` (site)| primary central hostnames                           | env2 central hostnames                              |
| `Node.SiteId` (site)                       | `site-a` / `site-b` / `site-c`                      | `site-x`                                            |
| `Database.ConfigurationDb`                 | `ScadaBridgeConfig`                                   | `ScadaBridgeConfig2`                                  |
| `Database.MachineDataDb`                   | `ScadaBridgeMachineData`                              | `ScadaBridgeMachineData2`                             |
| `Notification.FromAddress`                 | `scada-notifications@company.com`                   | `scada-notifications-env2@company.com`              |
| `Security.JwtSigningKey`                   | primary signing key                                 | a distinct env2 signing key                         |
| `Transport.SourceEnvironment`              | `docker-cluster`                                    | `docker-cluster-env2`                               |

`Transport.SourceEnvironment` is the field that ends up stamped into exported bundle manifests, so a bundle visibly self-identifies which environment produced it.

## Lifecycle Scripts

### `docker-env2/deploy.sh`
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

echo "=== ScadaBridge Env2 Docker Deploy ==="

# Reuse the primary build (same scadabridge:latest image)
"$SCRIPT_DIR/../docker/build.sh"

# Ensure env2 databases exist on the shared scadabridge-mssql
"$SCRIPT_DIR/init-db.sh"

echo "Deploying env2 containers..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --force-recreate
docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps

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 "  Traefik dashboard:    http://localhost:8181"
echo "Seed site:  docker-env2/seed-sites.sh"
```

### `docker-env2/init-db.sh`
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

if ! docker ps --format '{{.Names}}' | grep -q '^scadabridge-mssql$'; then
  echo "ERROR: scadabridge-mssql is not running. Start it: cd infra && docker compose up -d" >&2
  exit 1
fi

echo "Applying env2 database setup..."
docker exec -i scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd \
    -S localhost -U sa -P 'ScadaBridge_Dev1#' -C \
    < "$SCRIPT_DIR/../infra/mssql/setup-env2.sql"

echo "Env2 databases ready."
```

### `docker-env2/seed-sites.sh`
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="dotnet run --project $PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI --"
AUTH="--username multi-role --password password"
URL="--url http://localhost:9100"

echo "Creating Site-X on env2..."
$CLI $URL $AUTH site create \
    --name "Env2 Site X" \
    --identifier "site-x" \
    --description "Env2 test site - two-node cluster" \
    --node-a-address "akka.tcp://scadabridge@scadabridge-env2-site-x-a:8082" \
    --node-b-address "akka.tcp://scadabridge@scadabridge-env2-site-x-b:8082" \
    --grpc-node-a-address "http://scadabridge-env2-site-x-a:8083" \
    --grpc-node-b-address "http://scadabridge-env2-site-x-b:8083" \
|| echo "  (Site-X may already exist)"
```

### `docker-env2/teardown.sh`
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" down
```

### Operator Workflow

| Action                           | Command(s) |
|----------------------------------|-----------|
| First-time env2 bring-up         | `bash docker-env2/deploy.sh && bash docker-env2/seed-sites.sh` |
| Iterate on env2 after code edit  | `bash docker-env2/deploy.sh` |
| Iterate on both envs             | `bash docker/deploy.sh && bash docker-env2/deploy.sh` (build cached on 2nd) |
| Wipe env2 DB for clean re-import | `docker exec scadabridge-mssql sqlcmd ... DROP DATABASE ScadaBridgeConfig2; DROP DATABASE ScadaBridgeMachineData2;` then `bash docker-env2/deploy.sh` |
| Stop env2 only                   | `bash docker-env2/teardown.sh` |

## Transport Testing Workflow — The Whole Point of env2

**Golden-path demo:**

1. Set up primary with at least a few templates + one deployed instance.
2. `bash docker-env2/deploy.sh && bash docker-env2/seed-sites.sh` — env2 ConfigurationDB is empty.
3. Browser → http://localhost:9000 → `multi-role` login → **Design → Export Bundle** → select templates → review → set passphrase → download `.scadabundle`.
4. Browser → http://localhost:9101 → log in → **Admin → Import Bundle** → upload file → enter passphrase → review diff (all "Create" rows) → confirm.
5. Verify: env2's `Design → Templates` shows imported items; `Audit → Configuration Audit Log` shows rows tagged with the matching `BundleImportId`.
6. Deploy an imported template to env2's `site-x` to prove runtime-validity end-to-end.

**Manual tests env2 enables that mock-based tests cannot:**
- Conflict-resolution UI on re-import (Skip / Overwrite / Rename per row).
- Cross-environment audit correlation via `BundleImportId` chip.
- Schema-version gating (`SchemaVersionMajor` mismatch).
- Wrong-passphrase rejection + `MaxUnlockAttemptsPerSession=3` lockout.
- Round-trip parity: export from primary → import into env2 → export from env2 → re-import into primary with Skip-on-conflict. Revision hashes should match.

**What env2 does NOT test:**
- Multi-site Transport scenarios (env2 has one site by design).
- Site-clustered Transport flows (Transport is central-only).
- True air-gapped network isolation (env2 shares MSSQL/LDAP/SMTP — out of scope).

## Error Handling & Edge Cases

- **`init-db.sh`** fails fast with a clear message if `scadabridge-mssql` isn't running.
- **`deploy.sh`** runs with `set -euo pipefail` so any failed step halts cleanly.
- **MSSQL volume reset** — both the docker-entrypoint mount and the exec-based `init-db.sh` apply the same idempotent script; either path leaves env2 DBs ready.
- **Cluster cross-talk** — primary and env2 use the same Akka system name `scadabridge` but disjoint seed-node hostnames, so the gossip protocols cannot merge. Defensive: env2 appsettings are written from scratch, not sed'd from primary.
- **gRPC streaming** — env2 central uses container-name DNS (`http://scadabridge-env2-site-x-a:8083`) for site-x streams, populated by `seed-sites.sh`.
- **Cookie/JWT bleed** — different `JwtSigningKey` + different host origins (`localhost:9000` vs `localhost:9100`) mean sessions cannot cross envs.
- **Port collision** — host port range `91XX` non-overlapping with primary's `90XX`; confirmed all 10 ports free at design time. If an operator later remaps, Compose surfaces `bind: address already in use`.

## Testing

**No new automated tests are added.** This is infrastructure tooling — the Transport feature already has 39 unit + 26 integration tests. The deliverable is a manual verification checklist at `docs/plans/2026-05-24-second-environment-verification.md` mirroring the Transport manual checklist, walking through the Section 5 golden path.

**First-deploy smoke test:**
1. `docker ps` shows 5 new `scadabridge-env2-*` containers.
2. `curl http://localhost:9101/health/ready` returns green.
3. `curl http://localhost:9100/health/active` Traefik routes to active node.
4. Browser to http://localhost:9100 → `multi-role` login → Dashboard renders, Sites page is empty.
5. Run `docker-env2/seed-sites.sh` → site-x appears; health turns green within ~30s.

## Documentation Updates

- New: `docker-env2/README.md` — operator quick-start, copying the structure of `docker/README.md`.
- Update: `README.md` (project root) — add a "Second Environment" callout pointing to `docker-env2/README.md` with a one-sentence purpose statement.
- Update: `CLAUDE.md` — add `docker-env2/` to the Project Structure section so future sessions discover it.
- Update: `infra/README.md` — note that `setup-env2.sql` is mounted alongside `setup.sql`.

## Out-of-Scope Future Extensions

- A third / fourth environment by the same pattern (just bump prefix + port offset).
- `--env` flag retrofit on `docker/deploy.sh` if the directory duplication grows painful — not worth doing for just two environments.
- Air-gapped twin with its own MSSQL/LDAP/SMTP — straightforward extension of the same pattern if isolation requirements ever tighten.

## Acceptance Criteria

- [ ] `bash docker-env2/deploy.sh` brings up 5 containers cleanly on a machine where primary is already running.
- [ ] `bash docker-env2/seed-sites.sh` registers `site-x` and the site cluster reaches healthy state.
- [ ] http://localhost:9100 serves the env2 Central UI through Traefik with failover between 9101/9102.
- [ ] env2 reads/writes only `ScadaBridgeConfig2` / `ScadaBridgeMachineData2`; primary's DBs untouched after env2 deploy.
- [ ] `bash docker/deploy.sh && bash docker-env2/deploy.sh` succeeds in sequence; both stacks run concurrently.
- [ ] A bundle exported from primary can be imported into env2, with audit rows tagged by `BundleImportId` and visible in env2's Configuration Audit Log.
- [ ] Manual verification checklist completes end-to-end.
