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:
1
infra/.gitignore
vendored
Normal file
1
infra/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tools/.venv/
|
||||
95
infra/README.md
Normal file
95
infra/README.md
Normal 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
45
infra/docker-compose.yml
Normal 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
81
infra/glauth/config.toml
Normal 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
36
infra/mssql/setup.sql
Normal 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
138
infra/opcua/nodes.json
Normal 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
47
infra/teardown.sh
Executable 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
231
infra/tools/ldap_tool.py
Normal 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
204
infra/tools/mssql_tool.py
Normal 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
208
infra/tools/opcua_tool.py
Normal 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()
|
||||
3
infra/tools/requirements.txt
Normal file
3
infra/tools/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
opcua>=0.98.0
|
||||
ldap3>=2.9
|
||||
pymssql>=2.2.0
|
||||
Reference in New Issue
Block a user