#!/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. Encrypted secret columns (see ENCRYPTED_COLUMNS) are emitted as NULL: they hold ASP.NET Data Protection ciphertext, which is non-deterministic and bound to the source key ring, so a raw SQL dump can never replay a valid value. Re-populate them through the application after the seed runs (infra/reseed.sh does this via the ScadaLink CLI). """ 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) # (table, column) pairs encrypted at rest via ASP.NET Data Protection # (EncryptedStringConverter in ScadaLink.ConfigurationDatabase). Ciphertext is # non-deterministic and key-ring-bound, so it cannot be replayed from a static # SQL dump — the application would fail to decrypt it on read. These columns # are dumped as NULL; re-seed their values through the app (CLI / API) so the # value converter encrypts them against the target key ring. ENCRYPTED_COLUMNS = { ("ExternalSystemDefinitions", "AuthConfiguration"), ("SmtpConfigurations", "Credentials"), ("DatabaseConnectionDefinitions", "ConnectionString"), } # 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)") # Columns encrypted at rest cannot be dumped verbatim; emit NULL and # note it so the secret value is restored through the app afterwards. nulled = [c for c in columns if (table, c) in ENCRYPTED_COLUMNS] for c in nulled: out.append( "-- NOTE: [{}] is an encrypted secret column — dumped as NULL. " "Restore via the app (CLI/API) post-seed.".format(c) ) 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( "NULL" if (table, c) in ENCRYPTED_COLUMNS else quote(v) for c, v in zip(columns, 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()