feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding
The Test Run sandbox and Monaco analysis modelled a script API that had drifted from the site runtime's ScriptGlobals, so real scripts failed to compile in Test Run. Realign both to the runtime surface (Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the duplicate ScriptHost stub so the two cannot diverge again. - Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call) accept an anonymous object instead of a hand-built dictionary, via a shared ScriptArgs normalizer; existing dictionary calls still compile. - Test Run can optionally bind to a deployed instance, so Instance/ Attributes/CallScript route to it cross-site; adds site-side RouteToGetAttributes/RouteToSetAttributes handlers. - Adds Test Run panels to the API method and template script editors. - Fixes the TestDatabaseQuery seed script, which queried a table that never existed. Also commits unrelated in-progress work already in the tree: the health monitoring report loop, site streaming changes, and the Admin/Design data-connection and SMTP page reorganization.
This commit is contained in:
220
infra/tools/dump_seed.py
Executable file
220
infra/tools/dump_seed.py
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump design tables from ScadaLinkConfig to a replayable SQL seed file.
|
||||
|
||||
Usage:
|
||||
python3 infra/tools/dump_seed.py --output infra/mssql/seed-config.sql
|
||||
|
||||
Tables covered (insert order; reverse for delete):
|
||||
TemplateFolders, Templates, TemplateAttributes, TemplateScripts,
|
||||
TemplateAlarms, TemplateCompositions, SharedScripts, DataConnections,
|
||||
ExternalSystemDefinitions, ExternalSystemMethods
|
||||
|
||||
Excluded by design (per-environment, not design-time): Sites (seeded via
|
||||
seed-sites.sh), Instances + InstanceConnectionBindings + InstanceOverrides,
|
||||
NotificationLists/Recipients, SmtpConfigurations, ApiKeys, Areas,
|
||||
SiteScopeRules, LdapGroupMappings, DataProtectionKeys, audit, deployment.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
import pymssql
|
||||
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 1433
|
||||
DEFAULT_USER = "sa"
|
||||
DEFAULT_PASSWORD = "ScadaLink_Dev1#"
|
||||
DEFAULT_DATABASE = "ScadaLinkConfig"
|
||||
|
||||
INSERT_ORDER = [
|
||||
"TemplateFolders",
|
||||
"Templates",
|
||||
"TemplateAttributes",
|
||||
"TemplateScripts",
|
||||
"TemplateAlarms",
|
||||
"TemplateCompositions",
|
||||
"SharedScripts",
|
||||
"DataConnections",
|
||||
"ExternalSystemDefinitions",
|
||||
"ExternalSystemMethods",
|
||||
]
|
||||
|
||||
# Identity columns get IDENTITY_INSERT wrapped around inserts and are kept in
|
||||
# the column list. All listed tables happen to use Id as their identity.
|
||||
IDENTITY_TABLES = set(INSERT_ORDER)
|
||||
|
||||
# Templates has self-FK Templates.ParentTemplateId; emit a single batch that
|
||||
# inserts shallow rows first then deeper ones. pymssql returns rows in Id order
|
||||
# from our ORDER BY, which matches insertion order for this schema (parent Id
|
||||
# is always less than child Id in the live data).
|
||||
|
||||
|
||||
def quote(value):
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return "0x" + value.hex()
|
||||
if isinstance(value, datetime.datetime):
|
||||
return "'" + value.isoformat(sep=" ", timespec="microseconds") + "'"
|
||||
if isinstance(value, datetime.date):
|
||||
return "'" + value.isoformat() + "'"
|
||||
if isinstance(value, datetime.time):
|
||||
return "'" + value.isoformat(timespec="microseconds") + "'"
|
||||
if isinstance(value, datetime.timedelta):
|
||||
total = value.total_seconds()
|
||||
hours, rem = divmod(int(total), 3600)
|
||||
minutes, seconds = divmod(rem, 60)
|
||||
micros = value.microseconds
|
||||
return "'{:02d}:{:02d}:{:02d}.{:06d}'".format(hours, minutes, seconds, micros)
|
||||
text = str(value).replace("'", "''")
|
||||
return "N'" + text + "'"
|
||||
|
||||
|
||||
def get_columns(cursor, table):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = %s
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def dump(args):
|
||||
conn = pymssql.connect(
|
||||
server=args.host,
|
||||
port=args.port,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
out = []
|
||||
out.append("-- ScadaLink design-data seed.")
|
||||
out.append("-- Auto-generated by infra/tools/dump_seed.py against " + args.database + ".")
|
||||
out.append("-- Replays the design-time configuration (templates, scripts,")
|
||||
out.append("-- data connections, external systems). Idempotent: deletes")
|
||||
out.append("-- existing rows in the covered tables before inserting.")
|
||||
out.append("--")
|
||||
out.append("-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,")
|
||||
out.append("-- InstanceConnectionBindings, notifications, SMTP, API keys,")
|
||||
out.append("-- areas, LDAP mappings.")
|
||||
out.append("")
|
||||
out.append("SET NOCOUNT ON;")
|
||||
out.append("SET XACT_ABORT ON;")
|
||||
# sqlcmd defaults QUOTED_IDENTIFIER OFF; EF Core's filtered indexes
|
||||
# and computed columns require ON, so force it here.
|
||||
out.append("SET QUOTED_IDENTIFIER ON;")
|
||||
out.append("BEGIN TRAN;")
|
||||
out.append("")
|
||||
|
||||
# Wipe in reverse FK order. Beyond the design tables themselves, we also
|
||||
# clear instance + deployment rows because they FK to Templates and
|
||||
# DataConnections; without this, an idempotent replay against a populated
|
||||
# DB fails on the FK to DataConnections. On a fresh reseed (after
|
||||
# teardown.sh) these tables are already empty so the DELETEs are no-ops.
|
||||
out.append("-- Wipe existing design + dependent rows so the seed is idempotent.")
|
||||
out.append("-- Order matters: dependents first.")
|
||||
delete_order = [
|
||||
# Dependents on Instances / DataConnections / Sites.
|
||||
"DeployedConfigSnapshots",
|
||||
"DeploymentRecords",
|
||||
"InstanceAlarmOverrides",
|
||||
"InstanceAttributeOverrides",
|
||||
"InstanceConnectionBindings",
|
||||
"Instances",
|
||||
# Design tables themselves.
|
||||
"ExternalSystemMethods",
|
||||
"ExternalSystemDefinitions",
|
||||
"DataConnections",
|
||||
"SharedScripts",
|
||||
"TemplateCompositions",
|
||||
# Alarms reference scripts via OnTriggerScriptId; null it first so we
|
||||
# can delete scripts without FK violations.
|
||||
"UPDATE TemplateAlarms SET OnTriggerScriptId = NULL",
|
||||
"TemplateAlarms",
|
||||
"TemplateScripts",
|
||||
"TemplateAttributes",
|
||||
# Templates is self-referential and references TemplateCompositions
|
||||
# (OwnerCompositionId); null parent links first.
|
||||
"UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL",
|
||||
"Templates",
|
||||
# Folders is self-referential too.
|
||||
"UPDATE TemplateFolders SET ParentFolderId = NULL",
|
||||
"TemplateFolders",
|
||||
]
|
||||
for step in delete_order:
|
||||
if step.startswith("UPDATE "):
|
||||
out.append(step + ";")
|
||||
else:
|
||||
out.append("DELETE FROM " + step + ";")
|
||||
out.append("")
|
||||
|
||||
for table in INSERT_ORDER:
|
||||
columns = get_columns(cursor, table)
|
||||
if not columns:
|
||||
print("Skipping {} (no columns found)".format(table), file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Order by Id so self-referential rows insert in dependency order
|
||||
# (in the live data, parent Id < child Id by construction).
|
||||
order_clause = "ORDER BY Id" if "Id" in columns else ""
|
||||
cursor.execute(
|
||||
"SELECT [{}] FROM [{}] {}".format("], [".join(columns), table, order_clause)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
out.append("-- " + table + " (" + str(len(rows)) + " rows)")
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
col_list = ", ".join("[" + c + "]" for c in columns)
|
||||
identity = table in IDENTITY_TABLES
|
||||
if identity:
|
||||
out.append("SET IDENTITY_INSERT [{}] ON;".format(table))
|
||||
for row in rows:
|
||||
values = ", ".join(quote(v) for v in row)
|
||||
out.append(
|
||||
"INSERT INTO [{}] ({}) VALUES ({});".format(table, col_list, values)
|
||||
)
|
||||
if identity:
|
||||
out.append("SET IDENTITY_INSERT [{}] OFF;".format(table))
|
||||
out.append("")
|
||||
|
||||
out.append("COMMIT;")
|
||||
out.append("")
|
||||
|
||||
sql = "\n".join(out)
|
||||
with open(args.output, "w") as f:
|
||||
f.write(sql)
|
||||
|
||||
print("Wrote " + args.output + " (" + str(sum(1 for line in out if line.startswith('INSERT'))) + " inserts).")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--user", default=DEFAULT_USER)
|
||||
parser.add_argument("--password", default=DEFAULT_PASSWORD)
|
||||
parser.add_argument("--database", default=DEFAULT_DATABASE)
|
||||
parser.add_argument("--output", required=True, help="Path to write seed SQL")
|
||||
args = parser.parse_args()
|
||||
dump(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user