Add test infrastructure with Docker services, CLI tools, and resolve Phase 0 questions

Stand up local dev infrastructure (OPC UA, LDAP, MS SQL) with Docker Compose,
Python CLI tools for service interaction, and teardown script. Fix GLAuth config
mount, OPC PLC node format, and document actual DN/namespace behavior discovered
during testing. Resolve Q1-Q8,Q10: .NET 10, Akka.NET 1.5.x, monorepo with slnx,
appsettings JWT, Windows Server 2022 site target.
This commit is contained in:
Joseph Doherty
2026-03-16 14:03:12 -04:00
parent 7a0bd0f701
commit 652378b470
17 changed files with 1538 additions and 21 deletions

View File

@@ -9,6 +9,8 @@ This project contains design documentation for a distributed SCADA system built
- `Component-*.md` — Individual component design documents (one per component).
- `docs/plans/` — Design decision documents from refinement sessions.
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
- `test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL).
- `infra/` — Docker Compose and config files for local test services.
There is no source code in this project — only design documentation in markdown.

View File

@@ -6,30 +6,11 @@
## Open Questions
### Phase 0: Solution Skeleton
| # | Question | Context | Impact | Status |
|---|----------|---------|--------|--------|
| Q1 | What .NET version should we target? (.NET 8 LTS or .NET 9?) | Affects available APIs, Akka.NET compatibility, and support lifecycle. | Phase 0 — must decide before creating solution. | Open |
| Q2 | What Akka.NET version? (v1.5.x stable or preview features?) | Affects hosting APIs, serialization options, and cluster features. | Phase 0 — must decide before creating solution. | Open |
| Q3 | Should we use a monorepo (single .sln) or separate repos per component? | Affects CI/CD, versioning, and developer workflow. Monorepo recommended for a tightly coupled system. | Phase 0. | Open |
| Q4 | What CI/CD platform? (GitHub Actions, Azure DevOps, Jenkins, etc.) | Affects pipeline configuration in Phase 0. | Phase 0. | Open |
### Phase 1: Central Foundations
| # | Question | Context | Impact | Status |
|---|----------|---------|--------|--------|
| Q5 | What LDAP server will be used for dev/test? (Real AD, lightweight LDAP like OpenLDAP, or mock?) | Affects integration test setup for Security & Auth. | Phase 1 — needed for auth development. | Open |
| Q6 | What MS SQL version and hosting? (SQL Server 2019/2022, Azure SQL, local?) | Affects EF Core provider and connection string configuration. | Phase 1 — needed for Config DB. | Open |
| Q7 | Should the JWT signing key be stored in appsettings.json, Azure Key Vault, or elsewhere? | Affects security posture and operational procedures. Currently specified as "configuration." | Phase 1. | Open |
### Phase 3: Site Execution
| # | Question | Context | Impact | Status |
|---|----------|---------|--------|--------|
| Q8 | Is a real OPC UA server available for dev/test, or do we need a simulator? | Affects Data Connection Layer development. Best practices doc recommends mock protocol adapters. | Phase 3 — needed for DCL development. | Open |
| Q9 | What is the custom protocol? Is there an existing specification or SDK? | The design mentions "custom protocol" as a second adapter alongside OPC UA. Need details to implement. | Phase 3. | Open |
| Q10 | What is the target site hardware? (Server specs, network topology between nodes) | Affects performance tuning and Akka.NET dispatcher configuration. | Phase 3, Phase 8. | Open |
| Q9 | What is the custom protocol? Is there an existing specification or SDK? | The design mentions "custom protocol" as a second adapter alongside OPC UA. Need details to implement. | Phase 3. | Deferred — owner will provide details later |
### Phase 7: Integrations
@@ -52,4 +33,12 @@
| # | Question | Resolution | Date |
|---|----------|------------|------|
| | (none yet) | — | — |
| Q1 | What .NET version should we target? | .NET 10 LTS (released November 2025, supported through 2028). | 2026-03-16 |
| Q2 | What Akka.NET version? | Latest stable 1.5.x (currently 1.5.62). | 2026-03-16 |
| Q3 | Monorepo or separate repos? | Single monorepo with SLNX solution file (`.slnx`, the new XML-based format default in .NET 10). | 2026-03-16 |
| Q4 | What CI/CD platform? | None for now. No CI/CD pipeline. | 2026-03-16 |
| Q5 | What LDAP server for dev/test? | GLAuth (lightweight LDAP) in Docker. See `infra/glauth/config.toml` and `test_infra_ldap.md`. | 2026-03-16 |
| Q6 | What MS SQL version and hosting? | SQL Server 2022 Developer Edition in Docker. See `infra/docker-compose.yml` and `test_infra_db.md`. | 2026-03-16 |
| Q7 | JWT signing key storage? | `appsettings.json` (per environment). | 2026-03-16 |
| Q8 | OPC UA server for dev/test? | Azure IoT OPC PLC simulator in Docker. See `infra/opcua/nodes.json` and `test_infra_opcua.md`. | 2026-03-16 |
| Q10 | Target site hardware? | Windows Server 2022, 24 GB RAM, 1 TB drive, 16-core Xeon. | 2026-03-16 |

1
infra/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
tools/.venv/

95
infra/README.md Normal file
View File

@@ -0,0 +1,95 @@
# ScadaLink Test Infrastructure
Local Docker-based test services for ScadaLink development.
## Quick Start
```bash
docker compose up -d
```
This starts three services:
| Service | Port | Purpose |
|---------|------|---------|
| OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags |
| LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles |
| MS SQL 2022 | 1433 | Configuration and machine data databases |
## First-Time SQL Setup
The MS SQL container does not auto-run init scripts. After the first `docker compose up -d`, run:
```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.sql
```
This creates the `ScadaLinkConfig` and `ScadaLinkMachineData` databases and the `scadalink_app` login.
## Stopping & Teardown
**Stop containers** (data persists in SQL volume):
```bash
docker compose down
```
**Stop a single service** (leave the others running):
```bash
docker compose stop opcua # or: ldap, mssql
docker compose start opcua # bring it back without recreating
```
**Full teardown** (stop containers, delete SQL data volume, remove pulled images):
```bash
./teardown.sh
```
Or manually:
```bash
docker compose down -v # stop containers + delete SQL data volume
docker compose down -v --rmi all # also remove downloaded images
```
After a full teardown, the next `docker compose up -d` starts fresh — you'll need to re-run the SQL setup script.
## CLI Tools
Python CLI tools for interacting with the test services are in `tools/`.
**Set up a Python virtual environment** (one-time):
```bash
python3 -m venv tools/.venv && source tools/.venv/bin/activate
```
**Install dependencies** (one-time, with venv active):
```bash
pip install -r tools/requirements.txt
```
> The `.venv` directory is gitignored.
**Quick readiness check** (all three services, with venv active):
```bash
python tools/opcua_tool.py check
python tools/ldap_tool.py check
python tools/mssql_tool.py check
```
| Tool | Service | Key Commands |
|------|---------|-------------|
| `tools/opcua_tool.py` | OPC UA | `check`, `browse`, `read`, `write`, `monitor` |
| `tools/ldap_tool.py` | LDAP | `check`, `bind`, `search`, `users`, `groups` |
| `tools/mssql_tool.py` | MS SQL | `check`, `setup`, `query`, `tables` |
Each tool supports `--help` for full usage. See the per-service docs below for detailed examples.
## Detailed Documentation
See the project root for per-service setup guides:
- [test_infra.md](../test_infra.md) — Master test infrastructure overview
- [test_infra_opcua.md](../test_infra_opcua.md) — OPC UA server details
- [test_infra_ldap.md](../test_infra_ldap.md) — LDAP server details
- [test_infra_db.md](../test_infra_db.md) — MS SQL database details

45
infra/docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
services:
opcua:
image: mcr.microsoft.com/iotedge/opc-plc:latest
container_name: scadalink-opcua
ports:
- "50000:50000"
- "8080:8080"
volumes:
- ./opcua/nodes.json:/app/config/nodes.json:ro
command: >
--autoaccept
--unsecuretransport
--sph
--sn=5 --sr=10 --st=uint
--fn=5 --fr=1 --ft=uint
--gn=5
--nf=/app/config/nodes.json
--pn=50000
restart: unless-stopped
ldap:
image: glauth/glauth:latest
container_name: scadalink-ldap
ports:
- "3893:3893"
volumes:
- ./glauth/config.toml:/app/config/config.cfg:ro
restart: unless-stopped
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: scadalink-mssql
ports:
- "1433:1433"
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "ScadaLink_Dev1!"
MSSQL_PID: "Developer"
volumes:
- scadalink-mssql-data:/var/opt/mssql
- ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro
restart: unless-stopped
volumes:
scadalink-mssql-data:

81
infra/glauth/config.toml Normal file
View File

@@ -0,0 +1,81 @@
[ldap]
enabled = true
listen = "0.0.0.0:3893"
[ldaps]
enabled = false
[backend]
datastore = "config"
baseDN = "dc=scadalink,dc=local"
# ── Groups ──────────────────────────────────────────────────────────
[[groups]]
name = "SCADA-Admins"
gidnumber = 5501
[[groups]]
name = "SCADA-Designers"
gidnumber = 5502
[[groups]]
name = "SCADA-Deploy-All"
gidnumber = 5503
[[groups]]
name = "SCADA-Deploy-SiteA"
gidnumber = 5504
# ── Users ───────────────────────────────────────────────────────────
# All test passwords: "password"
# SHA256 of "password": 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
[[users]]
name = "admin"
givenname = "Admin"
sn = "User"
mail = "admin@scadalink.local"
uidnumber = 5001
primarygroup = 5501
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "designer"
givenname = "Designer"
sn = "User"
mail = "designer@scadalink.local"
uidnumber = 5002
primarygroup = 5502
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "deployer"
givenname = "Deployer"
sn = "User"
mail = "deployer@scadalink.local"
uidnumber = 5003
primarygroup = 5503
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "site-deployer"
givenname = "Site"
sn = "Deployer"
mail = "site-deployer@scadalink.local"
uidnumber = 5004
primarygroup = 5504
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
[[users]]
name = "multi-role"
givenname = "Multi"
sn = "Role"
mail = "multi-role@scadalink.local"
uidnumber = 5005
primarygroup = 5501
othergroups = [5502, 5503]
passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"

36
infra/mssql/setup.sql Normal file
View File

@@ -0,0 +1,36 @@
-- ScadaLink development database setup
-- Run against a fresh MS SQL 2022 instance.
-- EF Core migrations handle schema creation; this script only creates
-- the empty databases and the application login/user.
-- Create databases
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkConfig')
CREATE DATABASE ScadaLinkConfig;
GO
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkMachineData')
CREATE DATABASE ScadaLinkMachineData;
GO
-- Create application login
IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = 'scadalink_app')
CREATE LOGIN scadalink_app WITH PASSWORD = 'ScadaLink_Dev1!', DEFAULT_DATABASE = ScadaLinkConfig;
GO
-- Grant db_owner on ScadaLinkConfig
USE ScadaLinkConfig;
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 ScadaLinkMachineData
USE ScadaLinkMachineData;
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

138
infra/opcua/nodes.json Normal file
View File

@@ -0,0 +1,138 @@
{
"Folder": "ScadaLink",
"NodeList": [],
"FolderList": [
{
"Folder": "Motor",
"NodeList": [
{
"NodeId": "Motor.Speed",
"Name": "Speed",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Motor speed in RPM"
},
{
"NodeId": "Motor.Temperature",
"Name": "Temperature",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Motor bearing temperature in Celsius"
},
{
"NodeId": "Motor.Current",
"Name": "Current",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Motor current draw in Amps"
},
{
"NodeId": "Motor.Running",
"Name": "Running",
"DataType": "Boolean",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Motor running status"
},
{
"NodeId": "Motor.FaultCode",
"Name": "FaultCode",
"DataType": "UInt32",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Active fault code (0 = no fault)"
}
]
},
{
"Folder": "Pump",
"NodeList": [
{
"NodeId": "Pump.FlowRate",
"Name": "FlowRate",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Flow rate in liters per minute"
},
{
"NodeId": "Pump.Pressure",
"Name": "Pressure",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Discharge pressure in bar"
},
{
"NodeId": "Pump.Running",
"Name": "Running",
"DataType": "Boolean",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Pump running status"
}
]
},
{
"Folder": "Tank",
"NodeList": [
{
"NodeId": "Tank.Level",
"Name": "Level",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Tank level in percent"
},
{
"NodeId": "Tank.Temperature",
"Name": "Temperature",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Tank contents temperature in Celsius"
},
{
"NodeId": "Tank.HighLevel",
"Name": "HighLevel",
"DataType": "Boolean",
"ValueRank": -1,
"AccessLevel": "CurrentRead",
"Description": "High level alarm switch"
},
{
"NodeId": "Tank.LowLevel",
"Name": "LowLevel",
"DataType": "Boolean",
"ValueRank": -1,
"AccessLevel": "CurrentRead",
"Description": "Low level alarm switch"
}
]
},
{
"Folder": "Valve",
"NodeList": [
{
"NodeId": "Valve.Position",
"Name": "Position",
"DataType": "Double",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Valve position in percent open"
},
{
"NodeId": "Valve.Command",
"Name": "Command",
"DataType": "UInt32",
"ValueRank": -1,
"AccessLevel": "CurrentReadOrWrite",
"Description": "Valve command (0=Close, 1=Open, 2=Stop)"
}
]
}
]
}

47
infra/teardown.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Tear down ScadaLink test infrastructure.
#
# Usage:
# ./teardown.sh Stop containers and delete the SQL data volume
# ./teardown.sh --images Also remove downloaded Docker images
# ./teardown.sh --all Remove volumes, images, and the Python venv
set -euo pipefail
cd "$(dirname "$0")"
REMOVE_IMAGES=false
REMOVE_VENV=false
for arg in "$@"; do
case "$arg" in
--images) REMOVE_IMAGES=true ;;
--all) REMOVE_IMAGES=true; REMOVE_VENV=true ;;
-h|--help)
echo "Usage: ./teardown.sh [--images] [--all]"
echo " (no flags) Stop containers, delete SQL data volume"
echo " --images Also remove downloaded Docker images"
echo " --all Remove volumes, images, and Python venv"
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 1
;;
esac
done
echo "Stopping containers and removing SQL data volume..."
if $REMOVE_IMAGES; then
docker compose down -v --rmi all
else
docker compose down -v
fi
if $REMOVE_VENV && [ -d tools/.venv ]; then
echo "Removing Python virtual environment..."
rm -rf tools/.venv
fi
echo ""
echo "Teardown complete."
echo "To start fresh: docker compose up -d && python tools/mssql_tool.py setup --script mssql/setup.sql"

231
infra/tools/ldap_tool.py Normal file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""LDAP client tool for ScadaLink test infrastructure."""
import argparse
import sys
from ldap3 import Server, Connection, NONE, SUBTREE, SIMPLE
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 3893
DEFAULT_BASE_DN = "dc=scadalink,dc=local"
# GLAuth places users under ou=<PrimaryGroupName>,ou=users,dc=...
# The admin user (primarygroup SCADA-Admins) needs search capabilities in config.
DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local"
DEFAULT_BIND_PASSWORD = "password"
def make_connection(args):
"""Create and return an authenticated LDAP connection."""
server = Server(args.host, port=args.port, get_info=NONE)
bind_dn = getattr(args, "bind_dn", DEFAULT_BIND_DN) or DEFAULT_BIND_DN
bind_password = getattr(args, "bind_password", DEFAULT_BIND_PASSWORD) or DEFAULT_BIND_PASSWORD
conn = Connection(server, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True)
return conn
def cmd_check(args):
"""Test LDAP connectivity and base DN search."""
try:
conn = make_connection(args)
print(f"Connected to: {args.host}:{args.port}")
print(f"Bind DN: {DEFAULT_BIND_DN}")
conn.search(DEFAULT_BASE_DN, "(objectClass=*)", search_scope=SUBTREE, attributes=["dn"])
print(f"Base DN search ({DEFAULT_BASE_DN}): {len(conn.entries)} entries found")
for entry in conn.entries:
print(f" {entry.entry_dn}")
conn.unbind()
print("\nLDAP server is healthy.")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_bind(args):
"""Test user authentication via bind.
GLAuth DN format: cn=<user>,ou=<PrimaryGroup>,ou=users,dc=scadalink,dc=local
Since we don't know the user's primary group upfront, we search for the user first
to discover the full DN, then rebind with that DN.
"""
try:
# First, use admin to find the user's actual DN
admin_conn = make_connection(args)
admin_conn.search(
DEFAULT_BASE_DN,
f"(cn={args.user})",
search_scope=SUBTREE,
attributes=["cn"],
)
if not admin_conn.entries:
print(f"User not found: {args.user}", file=sys.stderr)
sys.exit(1)
# Find the user entry (skip group entries that also match cn)
user_dn = None
for entry in admin_conn.entries:
dn = entry.entry_dn
if dn.startswith(f"cn={args.user},"):
user_dn = dn
break
admin_conn.unbind()
if not user_dn:
print(f"Could not resolve DN for user: {args.user}", file=sys.stderr)
sys.exit(1)
# Now bind with the discovered DN
server = Server(args.host, port=args.port, get_info=NONE)
conn = Connection(server, user=user_dn, password=args.password, authentication=SIMPLE, auto_bind=True)
print(f"Bind successful: {user_dn}")
conn.unbind()
except Exception as e:
print(f"Bind failed for {args.user}: {e}", file=sys.stderr)
sys.exit(1)
def cmd_search(args):
"""Search with arbitrary LDAP filter."""
try:
conn = make_connection(args)
base = args.base or DEFAULT_BASE_DN
conn.search(base, args.filter, search_scope=SUBTREE, attributes=["*"])
print(f"Filter: {args.filter}")
print(f"Base: {base}")
print(f"Results: {len(conn.entries)}\n")
for entry in conn.entries:
print(f"DN: {entry.entry_dn}")
for attr in entry.entry_attributes:
print(f" {attr}: {entry[attr].value}")
print()
conn.unbind()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_users(args):
"""List all users with group memberships."""
try:
conn = make_connection(args)
# Find all groups to map GIDs and memberships
conn.search(DEFAULT_BASE_DN, "(objectClass=posixGroup)",
search_scope=SUBTREE, attributes=["cn", "gidNumber", "memberUid"])
groups_by_gid = {}
groups_with_members = {}
for entry in conn.entries:
gid = entry["gidNumber"].value if "gidNumber" in entry.entry_attributes else None
cn = entry["cn"].value
if gid:
groups_by_gid[int(gid)] = cn
member_uids = entry["memberUid"].value if "memberUid" in entry.entry_attributes else []
if isinstance(member_uids, str):
member_uids = [member_uids]
groups_with_members[cn] = member_uids
# Find all users
conn.search(DEFAULT_BASE_DN, "(objectClass=posixAccount)",
search_scope=SUBTREE, attributes=["cn", "mail", "gidNumber"])
print("Users:")
print(f"{'Username':<20} {'Email':<35} {'Primary Group':<25} {'Additional Groups'}")
print("-" * 110)
for entry in conn.entries:
cn = entry["cn"].value
mail = entry["mail"].value if "mail" in entry.entry_attributes else ""
gid = int(entry["gidNumber"].value) if "gidNumber" in entry.entry_attributes else None
primary = groups_by_gid.get(gid, str(gid)) if gid else ""
# Find additional groups via memberUid
additional = []
for group_name, members in groups_with_members.items():
if cn in members and group_name != primary:
additional.append(group_name)
print(f"{cn:<20} {mail:<35} {primary:<25} {', '.join(additional)}")
conn.unbind()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_groups(args):
"""List all groups with member counts."""
try:
conn = make_connection(args)
conn.search(DEFAULT_BASE_DN, "(objectClass=posixGroup)",
search_scope=SUBTREE, attributes=["cn", "gidNumber", "memberUid", "description"])
print("Groups:")
print(f"{'Group':<25} {'GID':<10} {'Members'}")
print("-" * 70)
for entry in conn.entries:
cn = entry["cn"].value
gid = entry["gidNumber"].value if "gidNumber" in entry.entry_attributes else ""
members = entry["memberUid"].value if "memberUid" in entry.entry_attributes else []
if isinstance(members, str):
members = [members]
member_list = ", ".join(members) if members else "(none)"
print(f"{cn:<25} {gid:<10} {member_list}")
conn.unbind()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="LDAP client tool for ScadaLink test infrastructure")
parser.add_argument("--host", default=DEFAULT_HOST, help=f"LDAP host (default: {DEFAULT_HOST})")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"LDAP port (default: {DEFAULT_PORT})")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check", help="Test LDAP connectivity")
bind_p = sub.add_parser("bind", help="Test user authentication")
bind_p.add_argument("--user", required=True, help="Username (e.g. designer)")
bind_p.add_argument("--password", default="password", help="Password (default: password)")
search_p = sub.add_parser("search", help="Search with LDAP filter")
search_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN")
search_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password")
search_p.add_argument("--filter", required=True, help="LDAP filter")
search_p.add_argument("--base", help=f"Search base (default: {DEFAULT_BASE_DN})")
users_p = sub.add_parser("users", help="List all users with group memberships")
users_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN")
users_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password")
groups_p = sub.add_parser("groups", help="List all groups with members")
groups_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN")
groups_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password")
args = parser.parse_args()
commands = {
"check": cmd_check,
"bind": cmd_bind,
"search": cmd_search,
"users": cmd_users,
"groups": cmd_groups,
}
commands[args.command](args)
if __name__ == "__main__":
main()

204
infra/tools/mssql_tool.py Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""MS SQL client tool for ScadaLink test infrastructure."""
import argparse
import sys
import pymssql
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 1433
DEFAULT_USER = "sa"
DEFAULT_PASSWORD = "ScadaLink_Dev1!"
EXPECTED_DBS = ["ScadaLinkConfig", "ScadaLinkMachineData"]
def get_connection(args, database=None):
"""Create and return a database connection."""
return pymssql.connect(
server=args.host,
port=args.port,
user=args.user,
password=args.password,
database=database or "master",
)
def cmd_check(args):
"""Connect and list databases, verify expected DBs exist."""
try:
conn = get_connection(args)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sys.databases ORDER BY name")
databases = [row[0] for row in cursor.fetchall()]
print(f"Connected to: {args.host}:{args.port}")
print(f"Databases ({len(databases)}):")
for db in databases:
marker = " <-- expected" if db in EXPECTED_DBS else ""
print(f" {db}{marker}")
missing = [db for db in EXPECTED_DBS if db not in databases]
if missing:
print(f"\nMissing expected databases: {', '.join(missing)}")
print("Run: python infra/tools/mssql_tool.py setup --script infra/mssql/setup.sql")
sys.exit(1)
else:
print("\nAll expected databases present.")
cursor.close()
conn.close()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_setup(args):
"""Execute a SQL script file."""
try:
with open(args.script, "r") as f:
sql = f.read()
except FileNotFoundError:
print(f"Error: script not found: {args.script}", file=sys.stderr)
sys.exit(1)
try:
conn = get_connection(args)
conn.autocommit(True)
cursor = conn.cursor()
# Split on GO statements (SQL Server batch separator)
batches = []
current = []
for line in sql.splitlines():
if line.strip().upper() == "GO":
if current:
batches.append("\n".join(current))
current = []
else:
current.append(line)
if current:
batches.append("\n".join(current))
for i, batch in enumerate(batches, 1):
batch = batch.strip()
if not batch:
continue
cursor.execute(batch)
print(f" Batch {i} executed.")
print(f"\nScript completed: {args.script} ({len(batches)} batches)")
cursor.close()
conn.close()
except Exception as e:
print(f"Error executing script: {e}", file=sys.stderr)
sys.exit(1)
def cmd_query(args):
"""Run an ad-hoc SQL query and print results."""
try:
conn = get_connection(args, database=args.database)
cursor = conn.cursor()
cursor.execute(args.sql)
if cursor.description:
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
# Calculate column widths
widths = [len(c) for c in columns]
for row in rows:
for i, val in enumerate(row):
widths[i] = max(widths[i], len(str(val)))
# Print header
header = " ".join(c.ljust(w) for c, w in zip(columns, widths))
print(header)
print(" ".join("-" * w for w in widths))
# Print rows
for row in rows:
print(" ".join(str(v).ljust(w) for v, w in zip(row, widths)))
print(f"\n({len(rows)} rows)")
else:
conn.commit()
print(f"Query executed. Rows affected: {cursor.rowcount}")
cursor.close()
conn.close()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_tables(args):
"""List all tables in a database."""
try:
conn = get_connection(args, database=args.database)
cursor = conn.cursor()
cursor.execute("""
SELECT s.name AS [schema], t.name AS [table],
SUM(p.rows) AS [rows]
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
GROUP BY s.name, t.name
ORDER BY s.name, t.name
""")
rows = cursor.fetchall()
if not rows:
print(f"No tables in {args.database}.")
else:
print(f"Tables in {args.database}:")
print(f"{'Schema':<15} {'Table':<40} {'Rows':<10}")
print("-" * 65)
for schema, table, count in rows:
print(f"{schema:<15} {table:<40} {count:<10}")
print(f"\n({len(rows)} tables)")
cursor.close()
conn.close()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="MS SQL client tool for ScadaLink test infrastructure")
parser.add_argument("--host", default=DEFAULT_HOST, help=f"SQL Server host (default: {DEFAULT_HOST})")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Port (default: {DEFAULT_PORT})")
parser.add_argument("--user", default=DEFAULT_USER, help=f"Username (default: {DEFAULT_USER})")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help=f"Password (default: {DEFAULT_PASSWORD})")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check", help="Connect and verify expected databases")
setup_p = sub.add_parser("setup", help="Execute a SQL script")
setup_p.add_argument("--script", required=True, help="Path to SQL script file")
query_p = sub.add_parser("query", help="Run an ad-hoc SQL query")
query_p.add_argument("--database", required=True, help="Database name")
query_p.add_argument("--sql", required=True, help="SQL query to execute")
tables_p = sub.add_parser("tables", help="List all tables in a database")
tables_p.add_argument("--database", required=True, help="Database name")
args = parser.parse_args()
commands = {
"check": cmd_check,
"setup": cmd_setup,
"query": cmd_query,
"tables": cmd_tables,
}
commands[args.command](args)
if __name__ == "__main__":
main()

208
infra/tools/opcua_tool.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""OPC UA client tool for ScadaLink test infrastructure."""
import argparse
import sys
import time
from opcua import Client, ua
DEFAULT_ENDPOINT = "opc.tcp://localhost:50000"
def cmd_check(args):
"""Connect and report server status."""
client = Client(args.endpoint)
try:
client.connect()
server_status = client.get_node(ua.ObjectIds.Server_ServerStatus).get_value()
print(f"Connected to: {args.endpoint}")
print(f"Server state: {server_status.State}")
print(f"Start time: {server_status.StartTime}")
print(f"Current time: {server_status.CurrentTime}")
print(f"Build info: {server_status.BuildInfo.ProductName} {server_status.BuildInfo.SoftwareVersion}")
ns = client.get_namespace_array()
print(f"\nNamespaces:")
for i, n in enumerate(ns):
print(f" {i}: {n}")
endpoints = client.get_endpoints()
print(f"\nEndpoints ({len(endpoints)}):")
for ep in endpoints:
print(f" {ep.EndpointUrl} [{ep.SecurityPolicyUri.split('#')[-1]}]")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.disconnect()
def cmd_browse(args):
"""Browse server node tree."""
client = Client(args.endpoint)
try:
client.connect()
if args.path:
root = client.get_objects_node()
node = root.get_child(args.path.split("."))
else:
node = client.get_objects_node()
browse_name = node.get_browse_name()
print(f"Browsing: {browse_name.Name} ({node.nodeid})")
print()
children = node.get_children()
for child in children:
name = child.get_browse_name()
node_class = child.get_node_class()
class_name = node_class.name if hasattr(node_class, "name") else str(node_class)
if node_class == ua.NodeClass.Variable:
try:
value = child.get_value()
dtype = child.get_data_type_as_variant_type()
print(f" {name.Name:<30} [{class_name}] {dtype.name} = {value}")
except Exception:
print(f" {name.Name:<30} [{class_name}]")
else:
print(f" {name.Name:<30} [{class_name}]")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.disconnect()
def cmd_read(args):
"""Read a node value."""
client = Client(args.endpoint)
try:
client.connect()
node = client.get_node(args.node)
data_value = node.get_data_value()
value = data_value.Value.Value
dtype = node.get_data_type_as_variant_type()
ts = data_value.SourceTimestamp
print(f"Node: {args.node}")
print(f"Value: {value}")
print(f"Data type: {dtype.name}")
print(f"Timestamp: {ts}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.disconnect()
def cmd_write(args):
"""Write a value to a node."""
type_map = {
"Double": (ua.VariantType.Double, float),
"Boolean": (ua.VariantType.Boolean, lambda v: v.lower() in ("true", "1", "yes")),
"UInt32": (ua.VariantType.UInt32, int),
"Int32": (ua.VariantType.Int32, int),
"String": (ua.VariantType.String, str),
}
if args.type not in type_map:
print(f"Error: unsupported type '{args.type}'. Use one of: {', '.join(type_map)}", file=sys.stderr)
sys.exit(1)
variant_type, converter = type_map[args.type]
converted_value = converter(args.value)
client = Client(args.endpoint)
try:
client.connect()
node = client.get_node(args.node)
dv = ua.DataValue(ua.Variant(converted_value, variant_type))
node.set_value(dv)
print(f"Wrote {converted_value} ({args.type}) to {args.node}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.disconnect()
def cmd_monitor(args):
"""Subscribe and print value changes."""
nodes = [n.strip() for n in args.nodes.split(",")]
client = Client(args.endpoint)
try:
client.connect()
class Handler:
def datachange_notification(self, node, val, data):
print(f" {node} = {val}")
handler = Handler()
sub = client.create_subscription(500, handler)
handles = []
for node_id in nodes:
node = client.get_node(node_id)
handle = sub.subscribe_data_change(node)
handles.append(handle)
name = node.get_browse_name()
print(f"Monitoring: {name.Name} ({node_id})")
print(f"\nListening for {args.duration}s...\n")
time.sleep(args.duration)
for handle in handles:
sub.unsubscribe(handle)
sub.delete()
print("\nDone.")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.disconnect()
def main():
parser = argparse.ArgumentParser(description="OPC UA client tool for ScadaLink test infrastructure")
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT, help=f"OPC UA endpoint (default: {DEFAULT_ENDPOINT})")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check", help="Connect and report server status")
browse_p = sub.add_parser("browse", help="Browse server node tree")
browse_p.add_argument("--path", help="Node path to browse (e.g. 3:OpcPlc.3:Telemetry)")
read_p = sub.add_parser("read", help="Read a node value")
read_p.add_argument("--node", required=True, help="Node ID (e.g. ns=3;s=Motor.Speed)")
write_p = sub.add_parser("write", help="Write a value to a node")
write_p.add_argument("--node", required=True, help="Node ID")
write_p.add_argument("--value", required=True, help="Value to write")
write_p.add_argument("--type", required=True, choices=["Double", "Boolean", "UInt32", "Int32", "String"],
help="Data type")
monitor_p = sub.add_parser("monitor", help="Subscribe and print value changes")
monitor_p.add_argument("--nodes", required=True, help="Comma-separated node IDs")
monitor_p.add_argument("--duration", type=int, default=10, help="Duration in seconds (default: 10)")
args = parser.parse_args()
commands = {
"check": cmd_check,
"browse": cmd_browse,
"read": cmd_read,
"write": cmd_write,
"monitor": cmd_monitor,
}
commands[args.command](args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
opcua>=0.98.0
ldap3>=2.9
pymssql>=2.2.0

87
test_infra.md Normal file
View File

@@ -0,0 +1,87 @@
# Test Infrastructure
This document describes the local Docker-based test infrastructure for ScadaLink development. Three services provide the external dependencies needed to run and test the system locally.
## Services
| Service | Image | Port(s) | Config |
|---------|-------|---------|--------|
| OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` |
| LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` |
| MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` |
## Quick Start
```bash
cd infra
docker compose up -d
```
After the first startup, run the SQL setup script to create databases and the application user:
```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.sql
```
## Per-Service Documentation
Each service has a dedicated document with configuration details, verification steps, and troubleshooting:
- [test_infra_opcua.md](test_infra_opcua.md) — OPC UA test server (Azure IoT OPC PLC)
- [test_infra_ldap.md](test_infra_ldap.md) — LDAP test server (GLAuth)
- [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database
## Connection Strings
For use in `appsettings.Development.json`:
```json
{
"ConnectionStrings": {
"ScadaLinkConfig": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true",
"ScadaLinkMachineData": "Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true"
},
"Ldap": {
"Server": "localhost",
"Port": 3893,
"BaseDN": "dc=scadalink,dc=local",
"UseSsl": false
},
"OpcUa": {
"EndpointUrl": "opc.tcp://localhost:50000"
}
}
```
## Stopping & Teardown
```bash
cd infra
docker compose down # stop containers, preserve SQL data volume
docker compose stop opcua # stop a single service (also: ldap, mssql)
```
**Full teardown** (removes volumes, optionally images and venv):
```bash
cd infra
./teardown.sh # stop containers + delete SQL data volume
./teardown.sh --images # also remove downloaded Docker images
./teardown.sh --all # also remove the Python venv
```
After a full teardown, the next `docker compose up -d` starts fresh — re-run the SQL setup script.
## Files
```
infra/
docker-compose.yml # All three services
teardown.sh # Teardown script (volumes, images, venv)
glauth/config.toml # LDAP users and groups
mssql/setup.sql # Database and user creation
opcua/nodes.json # Custom OPC UA tag definitions
tools/ # Python CLI tools (opcua, ldap, mssql)
README.md # Quick-start for the infra folder
```

126
test_infra_db.md Normal file
View File

@@ -0,0 +1,126 @@
# Test Infrastructure: MS SQL 2022 Database
## Overview
The test database uses Microsoft SQL Server 2022 Developer Edition running in Docker. It provides two empty databases for ScadaLink — schema creation is handled by EF Core migrations at application startup (dev mode).
## Image & Ports
- **Image**: `mcr.microsoft.com/mssql/server:2022-latest`
- **Port**: 1433
- **Edition**: Developer (free, full-featured)
## Credentials
| Account | Username | Password | Purpose |
|---------|----------|----------|---------|
| SA | `sa` | `ScadaLink_Dev1!` | Server admin (setup only) |
| App | `scadalink_app` | `ScadaLink_Dev1!` | Application login (db_owner on both databases) |
## Databases
| Database | Purpose |
|----------|---------|
| `ScadaLinkConfig` | Configuration Database component — templates, deployments, users, audit log |
| `ScadaLinkMachineData` | Machine/operational data storage |
Both databases are created empty by `infra/mssql/setup.sql`. EF Core migrations populate the schema.
## Data Persistence
SQL data is stored in the named Docker volume `scadalink-mssql-data`. Data survives container restarts and `docker compose down`. To reset the database completely:
```bash
docker compose down -v
docker compose up -d
# Re-run setup.sql after the container starts
```
## First-Time Setup
After the first `docker compose up -d`, run the setup script:
```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.sql
```
This creates the databases and the `scadalink_app` login. You only need to run this once (or again after deleting the volume).
## Connection Strings
For `appsettings.Development.json`:
```
Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true
```
```
Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true
```
## Verification
1. Check the container is running:
```bash
docker ps --filter name=scadalink-mssql
```
2. Query using `sqlcmd` inside the container:
```bash
docker exec -it scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'ScadaLink_Dev1!' -C \
-Q "SELECT name FROM sys.databases"
```
3. Verify the app login:
```bash
docker exec -it scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U scadalink_app -P 'ScadaLink_Dev1!' -C \
-d ScadaLinkConfig \
-Q "SELECT DB_NAME()"
```
## CLI Tool
The `infra/tools/mssql_tool.py` script provides a convenient CLI for interacting with the SQL Server.
**Install dependencies** (one-time):
```bash
pip install -r infra/tools/requirements.txt
```
**Commands**:
```bash
# Check connectivity and verify expected databases exist
python infra/tools/mssql_tool.py check
# Run the first-time setup script (uses autocommit mode for CREATE DATABASE)
python infra/tools/mssql_tool.py setup --script infra/mssql/setup.sql
# List tables in a database
python infra/tools/mssql_tool.py tables --database ScadaLinkConfig
# Run an ad-hoc query
python infra/tools/mssql_tool.py query --database ScadaLinkConfig --sql "SELECT name FROM sys.tables"
```
Use `--host`, `--port`, `--user`, `--password` to override defaults (localhost:1433, sa, ScadaLink_Dev1!). Run with `--help` for full usage.
## Relevance to ScadaLink Components
- **Configuration Database** — primary consumer; EF Core context targets `ScadaLinkConfig`.
- **Deployment Manager** — reads/writes deployment records in `ScadaLinkConfig`.
- **Template Engine** — reads/writes template definitions in `ScadaLinkConfig`.
- **Security & Auth** — user/role data stored in `ScadaLinkConfig`.
## Notes
- The `sa` password must meet SQL Server complexity requirements (uppercase, lowercase, digit, special character, 8+ characters).
- If the container fails to start, check Docker has at least 2GB RAM allocated (SQL Server minimum requirement).
- The setup script is idempotent — safe to run multiple times.

127
test_infra_ldap.md Normal file
View File

@@ -0,0 +1,127 @@
# Test Infrastructure: LDAP Server
## Overview
The test LDAP server uses [GLAuth](https://glauth.github.io/), a lightweight LDAP server backed by a TOML config file. It provides test users and groups that map to ScadaLink's role-based authorization model.
## Image & Ports
- **Image**: `glauth/glauth:latest`
- **LDAP port**: 3893 (plain LDAP, no TLS — dev only)
## Base DN
```
dc=scadalink,dc=local
```
## Test Users
All users have the password `password`.
| Username | Email | Primary Group | Additional Groups | ScadaLink Role |
|----------|-------|---------------|-------------------|----------------|
| `admin` | admin@scadalink.local | SCADA-Admins | — | Full administrator |
| `designer` | designer@scadalink.local | SCADA-Designers | — | Template designer |
| `deployer` | deployer@scadalink.local | SCADA-Deploy-All | — | Deploy to all sites |
| `site-deployer` | site-deployer@scadalink.local | SCADA-Deploy-SiteA | — | Deploy to SiteA only |
| `multi-role` | multi-role@scadalink.local | SCADA-Admins | SCADA-Designers, SCADA-Deploy-All | Multiple roles |
## Groups
| Group | GID | Purpose |
|-------|-----|---------|
| SCADA-Admins | 5501 | Full administrative access |
| SCADA-Designers | 5502 | Template creation and editing |
| SCADA-Deploy-All | 5503 | Deploy to any site |
| SCADA-Deploy-SiteA | 5504 | Deploy to SiteA only (site-scoped) |
## User DNs
Users bind with their full DN, which includes the primary group as an OU:
```
cn=<username>,ou=<PrimaryGroupName>,ou=users,dc=scadalink,dc=local
```
For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local`
The full DNs for all test users:
| Username | Full DN |
|----------|---------|
| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local` |
| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=scadalink,dc=local` |
| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=scadalink,dc=local` |
| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=scadalink,dc=local` |
| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local` |
## Verification
1. Check the container is running:
```bash
docker ps --filter name=scadalink-ldap
```
2. Test a user bind with `ldapsearch`:
```bash
ldapsearch -H ldap://localhost:3893 \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local" \
-w password \
-b "dc=scadalink,dc=local" \
"(objectClass=*)"
```
3. Search for group membership:
```bash
ldapsearch -H ldap://localhost:3893 \
-D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local" \
-w password \
-b "dc=scadalink,dc=local" \
"(cn=multi-role)"
```
## CLI Tool
The `infra/tools/ldap_tool.py` script provides a convenient CLI for interacting with the LDAP server.
**Install dependencies** (one-time):
```bash
pip install -r infra/tools/requirements.txt
```
**Commands**:
```bash
# Check LDAP connectivity and list entries
python infra/tools/ldap_tool.py check
# Test user authentication
python infra/tools/ldap_tool.py bind --user designer --password password
# List all users with group memberships
python infra/tools/ldap_tool.py users
# List all groups with members
python infra/tools/ldap_tool.py groups
# Search with an arbitrary LDAP filter
python infra/tools/ldap_tool.py search --filter "(cn=multi-role)"
```
Use `--host` and `--port` to override defaults (localhost:3893). Run with `--help` for full usage.
## Relevance to ScadaLink Components
- **Security & Auth** — test LDAP bind authentication, group-to-role mapping, and multi-group resolution.
- **Central UI** — test login flows with different role combinations.
## Notes
- GLAuth uses plain LDAP on port 3893. ScadaLink's Security & Auth component requires LDAPS/StartTLS in production. For dev testing, configure the LDAP client to allow plaintext connections.
- To add users or groups, edit `infra/glauth/config.toml` locally and restart the container: `docker compose restart ldap`. Note that the file is named `config.toml` on the host but is mounted into the container as `/app/config/config.cfg` (the path GLAuth expects).
- The `admin` user is configured with `[[users.capabilities]]` (`action = "search"`, `object = "*"`) in the GLAuth config. This grants the admin account permission to perform LDAP search operations, which is required for user/group lookups.
- Anonymous bind is not allowed. All LDAP operations (including searches) require an authenticated bind. Use the `admin` account for search operations.

97
test_infra_opcua.md Normal file
View File

@@ -0,0 +1,97 @@
# Test Infrastructure: OPC UA Server
## Overview
The test OPC UA server uses [Azure IoT OPC PLC](https://github.com/Azure-Samples/iot-edge-opc-plc), a simulated OPC UA server that generates realistic data. It is configured with custom nodes that match ScadaLink attribute patterns.
## Image & Ports
- **Image**: `mcr.microsoft.com/iotedge/opc-plc:latest`
- **OPC UA endpoint**: `opc.tcp://localhost:50000`
- **Web/config UI**: `http://localhost:8080`
## Startup Flags
```
--autoaccept Accept all client certificates
--unsecuretransport Enable plain (non-TLS) OPC UA connections for dev tools
--sph Show PLC heartbeat on console
--sn=5 --sr=10 --st=uint 5 slow-changing nodes (10s cycle, uint)
--fn=5 --fr=1 --ft=uint 5 fast-changing nodes (1s cycle, uint)
--gn=5 5 stepping nodes
--nf=/app/config/nodes.json Custom node definitions
--pn=50000 Listen port
```
## Custom Nodes
The file `infra/opcua/nodes.json` defines a single `ConfigFolder` object (not an array) with a root "ScadaLink" folder containing four equipment subfolders. Tags match typical ScadaLink instance attribute patterns:
| Folder | Tags | Types |
|--------|------|-------|
| Motor | Speed, Temperature, Current, Running, FaultCode | Double, Boolean, UInt32 |
| Pump | FlowRate, Pressure, Running | Double, Boolean |
| Tank | Level, Temperature, HighLevel, LowLevel | Double, Boolean |
| Valve | Position, Command | Double, UInt32 |
All custom nodes hold their initial/default values (0 for numerics, false for booleans) until written. OPC PLC's custom node format does not support random value generation for these nodes.
Custom nodes live in namespace 3 (`http://microsoft.com/Opc/OpcPlc/`). Node IDs follow the pattern `ns=3;s=<Folder>.<Tag>` (e.g., `ns=3;s=Motor.Speed`).
The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|Valve`.
## Verification
1. Check the container is running:
```bash
docker ps --filter name=scadalink-opcua
```
2. Verify the OPC UA endpoint using any OPC UA client (e.g., UaExpert, opcua-commander):
```bash
# Using opcua-commander (npm install -g opcua-commander)
opcua-commander -e opc.tcp://localhost:50000
```
3. Check the web UI at `http://localhost:8080` for server status and node listing.
## CLI Tool
The `infra/tools/opcua_tool.py` script provides a convenient CLI for interacting with the OPC UA server.
**Install dependencies** (one-time):
```bash
pip install -r infra/tools/requirements.txt
```
**Commands**:
```bash
# Check server status, namespaces, and endpoints
python infra/tools/opcua_tool.py check
# Browse the Objects folder (top-level)
python infra/tools/opcua_tool.py browse
# Browse a specific equipment folder
python infra/tools/opcua_tool.py browse --path "3:OpcPlc.3:ScadaLink.3:Motor"
# Read a tag value
python infra/tools/opcua_tool.py read --node "ns=3;s=Motor.Speed"
# Write a value to a tag
python infra/tools/opcua_tool.py write --node "ns=3;s=Motor.Running" --value true --type Boolean
# Monitor value changes for 15 seconds
python infra/tools/opcua_tool.py monitor --nodes "ns=3;s=Motor.Speed,ns=3;s=Pump.FlowRate" --duration 15
```
Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). Run with `--help` for full usage.
## Relevance to ScadaLink Components
- **Data Connection Layer** — connect to this server to test OPC UA subscription, read/write, and reconnection behavior.
- **Site Runtime / Instance Actors** — deploy instances with tag mappings pointing at these nodes.
- **Template Engine** — design templates with attributes matching the Motor/Pump/Tank/Valve folder structure.