using Dapper; using GB5Shared.Connection; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.Response; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using System.Diagnostics; using TCMSBLL.Interfaces; using TCMSDAL.DTO; using TCMSDAL.Interfaces; using static GB5Shared.GB5Constant.Constant; namespace TCMSBLL.Implementations; /// /// Business logic for Test Environment Management. /// Clone/Sync: dual-engine — SQL Server (BACKUP+RESTORE) and PostgreSQL (CREATE DATABASE TEMPLATE). /// Snapshot: SQL Server (COW sparse file) and PostgreSQL (full-copy template with naming convention). /// All mutating operations write to TTESTENVRESETLOG for audit trail. /// public class TCMSTestEnvBLL : ITCMSTestEnvBLL { private readonly ITCMSTestEnvDAL _Dal; private readonly IApplicationConnection _AppConn; private readonly ILogger _Logger; private const string CryptoKey = "GB5"; public TCMSTestEnvBLL( ITCMSTestEnvDAL dal, IApplicationConnection appConn, ILogger logger) { _Dal = dal; _AppConn = appConn; _Logger = logger; } // ── Read ────────────────────────────────────────────────────────────────── public async Task> GetTestEnvironmentsAsync( LoginDTO login, CancellationToken ct) => await _Dal.GetTestEnvironmentsAsync(login, ct).ConfigureAwait(false); public async Task> GetMServerSelectListAsync( LoginDTO login, CancellationToken ct) => await _Dal.GetMServerSelectListAsync(ct).ConfigureAwait(false); /// /// Returns decrypted credentials for a TEST instance (DBINSTANCETYPE=1). /// Returns null for LIVE configs — callers treat null as validation error. /// public async Task GetTestDbConnectionAsync( int serverConfigId, LoginDTO login, CancellationToken ct) { var raw = await _Dal.GetTestDbConnectionRawAsync(serverConfigId, ct).ConfigureAwait(false); if (raw is null || raw.DbInstanceType != 1) return null; raw.UserName = _AppConn.Decrypt(raw.UserName, CryptoKey); raw.Password = _AppConn.Decrypt(raw.Password, CryptoKey); return raw; } public async Task> GetResetHistoryAsync( int serverConfigId, LoginDTO login, CancellationToken ct) => await _Dal.GetResetHistoryAsync(serverConfigId, login, ct).ConfigureAwait(false); public async Task> GetSnapshotsAsync( int serverConfigId, LoginDTO login, CancellationToken ct) { var conn = await GetDecryptedConnectionAsync(serverConfigId, ct).ConfigureAwait(false); if (conn is null) return Enumerable.Empty(); string adminCs = BuildAdminCs(conn); return await _Dal.GetSnapshotsAsync(adminCs, conn.DatabaseName, conn.DatabaseType, ct) .ConfigureAwait(false); } // ── Register / Deregister ───────────────────────────────────────────────── public async Task RegisterTestDbAsync( TCMSRegisterTestDbDTO dto, LoginDTO login, CancellationToken ct) { try { if (string.IsNullOrWhiteSpace(dto.ConnectionName)) return "Connection Name is required."; if (string.IsNullOrWhiteSpace(dto.DatabaseName)) return "Database Name is required."; if (dto.ServerId == 0) return "Server is required."; if (await _Dal.ConnectionNameExistsAsync(dto.ConnectionName, ct).ConfigureAwait(false)) return $"Connection name '{dto.ConnectionName}' is already registered."; // Encrypt credentials before persisting to MSERVERCONFIG dto.DatabaseUserName = _AppConn.Encrypt(dto.DatabaseUserName, CryptoKey); dto.DatabasePassword = _AppConn.Encrypt(dto.DatabasePassword, CryptoKey); dto.ClientId = login.ClientId; dto.ClientSiteId = 1; // LoginDTO doesn't expose ClientSiteId; default to 1 int newId = await _Dal.RegisterTestDbAsync(dto, login, ct).ConfigureAwait(false); _Logger.LogInformation("Test DB registered: {Name} (ConfigId={Id}) by {User}", dto.ConnectionName, newId, login.UserId); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } catch (Exception ex) { _Logger.LogError(ex, "RegisterTestDb failed for {ConnectionName}", dto.ConnectionName); throw; } } public async Task DeregisterTestDbAsync( TCMSDeregisterTestDbDTO dto, LoginDTO login, CancellationToken ct) { try { if (dto.ServerConfigId == 0) return "ServerConfigId is required."; if (dto.DropPhysicalDb) { var conn = await GetDecryptedConnectionAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (conn is not null) { string adminCs = BuildAdminCs(conn); string dropSql = conn.DatabaseType == DBTYPE.POSTGRESQL ? $"DROP DATABASE IF EXISTS \"{conn.DatabaseName}\"" : BuildKillAndDropDdl(conn.DatabaseName); await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, dropSql, conn.DatabaseType, ct) .ConfigureAwait(false); _Logger.LogInformation("Physical DB dropped: {Db} (ConfigId={Id}) by {User}", conn.DatabaseName, dto.ServerConfigId, login.UserId); } } await _Dal.DeregisterTestDbAsync(dto.ServerConfigId, login, ct).ConfigureAwait(false); _Logger.LogInformation("Test DB deregistered: ConfigId={Id} by {User}", dto.ServerConfigId, login.UserId); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { _Logger.LogError(ex, "DeregisterTestDb failed for ConfigId={Id}", dto.ServerConfigId); throw; } } // ── Clone ───────────────────────────────────────────────────────────────── public async Task CloneFromControlAsync( TCMSCloneDbDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.ControlDbName)) return "Control DB name is required."; if (string.IsNullOrWhiteSpace(dto.RunDbName)) return "Run DB name is required."; if (string.IsNullOrWhiteSpace(dto.ConnectionName)) return "Connection name is required."; if (await _Dal.ConnectionNameExistsAsync(dto.ConnectionName, ct).ConfigureAwait(false)) return $"Connection name '{dto.ConnectionName}' is already registered."; var controlConfig = await _AppConn.DatabaseConnectionObject(dto.ControlDbName) .ConfigureAwait(false); string adminUser = _AppConn.Decrypt(controlConfig.DatabaseUserName, CryptoKey); string adminPass = _AppConn.Decrypt(controlConfig.DatabasePassword, CryptoKey); int dbType = controlConfig.DbType; string adminCs = dbType == DBTYPE.POSTGRESQL ? BuildPgAdminCs(controlConfig.ServerIP, adminUser, adminPass, controlConfig.DatabasePort) : BuildSqlAdminCs(controlConfig.ServerIP, adminUser, adminPass, controlConfig.DatabasePort); int logId = await _Dal.InsertResetLogAsync(new TCMSTestEnvResetLogDTO { ServerConfigId = 0, ConnectionName = dto.ConnectionName, ControlSource = dto.ControlDbName, TriggeredBy = dto.TriggeredBy, Success = false, }, login, ct).ConfigureAwait(false); var sw = Stopwatch.StartNew(); try { await CloneDatabaseAsync(adminCs, dbType, dto.ControlDbName, dto.RunDbName, ct) .ConfigureAwait(false); var register = new TCMSRegisterTestDbDTO { ConnectionName = dto.ConnectionName, ServerId = controlConfig.ServerId, DatabaseName = dto.RunDbName, DatabaseType = dbType, ControlSourceDbName = dto.ControlDbName, DatabaseUserName = controlConfig.DatabaseUserName, // already encrypted in MSERVERCONFIG DatabasePassword = controlConfig.DatabasePassword, Port = controlConfig.DatabasePort, ClientId = login.ClientId, ClientSiteId = 1, // LoginDTO doesn't expose ClientSiteId; default to 1 }; int newConfigId = await _Dal.RegisterTestDbAsync(register, login, ct).ConfigureAwait(false); sw.Stop(); await _Dal.UpdateResetLogOutcomeAsync(logId, (int)sw.ElapsedMilliseconds, true, null, login, ct) .ConfigureAwait(false); _Logger.LogInformation("CloneFromControl: {Ctrl}→{Run} ({Ms}ms) by {User}", dto.ControlDbName, dto.RunDbName, sw.ElapsedMilliseconds, dto.TriggeredBy); return $"{SuccessResponse.SaveSuccessMessage} {newConfigId}"; } catch (Exception ex) { sw.Stop(); await _Dal.UpdateResetLogOutcomeAsync(logId, (int)sw.ElapsedMilliseconds, false, ex.Message, login, ct) .ConfigureAwait(false); _Logger.LogError(ex, "CloneFromControl failed: {Ctrl}→{Run}", dto.ControlDbName, dto.RunDbName); throw; } } // ── Sync (re-clone) ─────────────────────────────────────────────────────── public async Task SyncFromControlAsync( TCMSSyncFromControlDTO dto, LoginDTO login, CancellationToken ct) { if (dto.ServerConfigId == 0) return "ServerConfigId is required."; var env = await _Dal.GetTestEnvByIdAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (env is null) return "Test environment not found."; if (string.IsNullOrWhiteSpace(env.ControlSourceDb)) return "No control source DB recorded for this environment. Use CloneFromControl first."; var conn = await GetDecryptedConnectionAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (conn is null) return "Could not resolve connection details for this environment."; string adminCs = BuildAdminCs(conn); int logId = await _Dal.InsertResetLogAsync(new TCMSTestEnvResetLogDTO { ServerConfigId = dto.ServerConfigId, ConnectionName = env.ConnectionName, ControlSource = env.ControlSourceDb, TriggeredBy = dto.TriggeredBy, Success = false, }, login, ct).ConfigureAwait(false); var sw = Stopwatch.StartNew(); try { string dropSql = conn.DatabaseType == DBTYPE.POSTGRESQL ? $"DROP DATABASE IF EXISTS \"{conn.DatabaseName}\"" : BuildKillAndDropDdl(conn.DatabaseName); await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, dropSql, conn.DatabaseType, ct) .ConfigureAwait(false); await CloneDatabaseAsync(adminCs, conn.DatabaseType, env.ControlSourceDb, conn.DatabaseName, ct) .ConfigureAwait(false); sw.Stop(); await _Dal.UpdateResetLogOutcomeAsync(logId, (int)sw.ElapsedMilliseconds, true, null, login, ct) .ConfigureAwait(false); _Logger.LogInformation("SyncFromControl: {Run} reset from {Ctrl} ({Ms}ms) by {User}", conn.DatabaseName, env.ControlSourceDb, sw.ElapsedMilliseconds, dto.TriggeredBy); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { sw.Stop(); await _Dal.UpdateResetLogOutcomeAsync(logId, (int)sw.ElapsedMilliseconds, false, ex.Message, login, ct) .ConfigureAwait(false); _Logger.LogError(ex, "SyncFromControl failed for ConfigId={Id}", dto.ServerConfigId); throw; } } // ── Snapshots ───────────────────────────────────────────────────────────── public async Task CreateSnapshotAsync( TCMSCreateSnapshotDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.SnapshotLabel)) return "Snapshot label is required."; var conn = await GetDecryptedConnectionAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (conn is null) return "Test environment not found."; string adminCs = BuildAdminCs(conn); string snapshotName = $"{conn.DatabaseName}_snap_{Sanitize(dto.SnapshotLabel)}"; string ddl = conn.DatabaseType == DBTYPE.POSTGRESQL ? $"CREATE DATABASE \"{snapshotName}\" TEMPLATE \"{conn.DatabaseName}\"" : await BuildSqlSnapshotDdlAsync(adminCs, conn.DatabaseName, snapshotName) .ConfigureAwait(false); await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, ddl, conn.DatabaseType, ct) .ConfigureAwait(false); _Logger.LogInformation("Snapshot created: {Snap} from {Db} by {User}", snapshotName, conn.DatabaseName, login.UserId); return $"{SuccessResponse.SaveSuccessMessage} {snapshotName}"; } public async Task RestoreToSnapshotAsync( TCMSRestoreToSnapshotDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.SnapshotLabel)) return "Snapshot label is required."; var conn = await GetDecryptedConnectionAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (conn is null) return "Test environment not found."; string adminCs = BuildAdminCs(conn); string snapshotName = $"{conn.DatabaseName}_snap_{Sanitize(dto.SnapshotLabel)}"; string ddl = conn.DatabaseType == DBTYPE.POSTGRESQL // PostgreSQL: drop run DB, recreate from snapshot template ? $"DROP DATABASE IF EXISTS \"{conn.DatabaseName}\";" + $"CREATE DATABASE \"{conn.DatabaseName}\" TEMPLATE \"{snapshotName}\"" // SQL Server: native snapshot restore (requires SINGLE_USER mode) : $"ALTER DATABASE [{conn.DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;" + $"RESTORE DATABASE [{conn.DatabaseName}] FROM DATABASE_SNAPSHOT = N'{snapshotName}';" + $"ALTER DATABASE [{conn.DatabaseName}] SET MULTI_USER;"; await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, ddl, conn.DatabaseType, ct) .ConfigureAwait(false); _Logger.LogInformation("Restored {Db} to snapshot {Snap} by {User}", conn.DatabaseName, snapshotName, login.UserId); return SuccessResponse.UpdateSuccess; } public async Task DeleteSnapshotAsync( TCMSDeleteSnapshotDTO dto, LoginDTO login, CancellationToken ct) { if (string.IsNullOrWhiteSpace(dto.SnapshotLabel)) return "Snapshot label is required."; var conn = await GetDecryptedConnectionAsync(dto.ServerConfigId, ct).ConfigureAwait(false); if (conn is null) return "Test environment not found."; string adminCs = BuildAdminCs(conn); string snapshotName = $"{conn.DatabaseName}_snap_{Sanitize(dto.SnapshotLabel)}"; string ddl = conn.DatabaseType == DBTYPE.POSTGRESQL ? $"DROP DATABASE IF EXISTS \"{snapshotName}\"" : $"DROP DATABASE [{snapshotName}]"; await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, ddl, conn.DatabaseType, ct) .ConfigureAwait(false); _Logger.LogInformation("Snapshot dropped: {Snap} by {User}", snapshotName, login.UserId); return SuccessResponse.DeleteSuccessMessage; } // ── Private helpers ─────────────────────────────────────────────────────── private async Task GetDecryptedConnectionAsync( int serverConfigId, CancellationToken ct) { var raw = await _Dal.GetTestDbConnectionRawAsync(serverConfigId, ct).ConfigureAwait(false); if (raw is null || raw.DbInstanceType != 1) return null; raw.UserName = _AppConn.Decrypt(raw.UserName, CryptoKey); raw.Password = _AppConn.Decrypt(raw.Password, CryptoKey); return raw; } private static string BuildAdminCs(TCMSTestDbConnectionDTO conn) => conn.DatabaseType == DBTYPE.POSTGRESQL ? BuildPgAdminCs(conn.ServerIP, conn.UserName, conn.Password, conn.Port) : BuildSqlAdminCs(conn.ServerIP, conn.UserName, conn.Password, conn.Port); private static string BuildSqlAdminCs(string ip, string user, string pass, string port) { string src = int.TryParse(port, out int p) && p > 0 ? $"{ip},{p}" : ip; return new SqlConnectionStringBuilder { DataSource = src, UserID = user, Password = pass, TrustServerCertificate = true, Encrypt = false, }.ConnectionString; } private static string BuildPgAdminCs(string ip, string user, string pass, string port) { int pg = int.TryParse(port, out int p) && p > 0 ? p : 5432; return $"Host={ip};Port={pg};Username={user};Password={pass};Database=postgres"; } private async Task CloneDatabaseAsync( string adminCs, int dbType, string sourceDb, string targetDb, CancellationToken ct) { if (dbType == DBTYPE.POSTGRESQL) { string ddl = $"CREATE DATABASE \"{targetDb}\" TEMPLATE \"{sourceDb}\""; await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, ddl, dbType, ct).ConfigureAwait(false); } else { // SQL Server: backup → restore (works across instances and volumes) string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); string serverIp = new SqlConnectionStringBuilder(adminCs).DataSource.Split(',')[0]; string backupPath = $@"\\{serverIp}\SQLBackup\{targetDb}_{timestamp}.bak"; string backupDdl = $@" BACKUP DATABASE [{sourceDb}] TO DISK = N'{backupPath}' WITH INIT, COMPRESSION, STATS = 10"; await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, backupDdl, dbType, ct).ConfigureAwait(false); string restoreDdl = await BuildRestoreWithMoveAsync(adminCs, sourceDb, targetDb, backupPath) .ConfigureAwait(false); await _Dal.ExecuteDdlOnTargetServerAsync(adminCs, restoreDdl, dbType, ct).ConfigureAwait(false); } } // Queries sys.master_files to build per-file MOVE clauses for RESTORE private static async Task BuildRestoreWithMoveAsync( string adminCs, string sourceDb, string targetDb, string backupPath) { using var conn = new SqlConnection(adminCs); var files = (await conn.QueryAsync<(string LogicalName, string PhysicalName, string Type)>( "SELECT name, physical_name, type_desc " + "FROM sys.master_files WHERE database_id = DB_ID(@Db)", new { Db = sourceDb })).ToList(); var moves = string.Join(",\n", files.Select(f => { string ext = f.Type == "LOG" ? "_log.ldf" : ".mdf"; string dir = System.IO.Path.GetDirectoryName(f.PhysicalName) ?? @"C:\Data"; string dest = System.IO.Path.Combine(dir, $"{targetDb}{ext}"); return $"MOVE N'{f.LogicalName}' TO N'{dest}'"; })); return $@" RESTORE DATABASE [{targetDb}] FROM DISK = N'{backupPath}' WITH REPLACE, RECOVERY, {moves}"; } // Queries sys.master_files (data files only) to build snapshot ON clause private static async Task BuildSqlSnapshotDdlAsync( string adminCs, string runDbName, string snapshotName) { using var conn = new SqlConnection(adminCs); var dataFiles = (await conn.QueryAsync<(string LogicalName, string PhysicalName)>( "SELECT name, physical_name FROM sys.master_files " + "WHERE database_id = DB_ID(@Db) AND type = 0", // type=0 = data new { Db = runDbName })).ToList(); var onClause = string.Join(",\n", dataFiles.Select(f => { string dir = System.IO.Path.GetDirectoryName(f.PhysicalName) ?? @"C:\Data"; string snap = System.IO.Path.Combine(dir, $"{snapshotName}_{f.LogicalName}.ss"); return $"(NAME = N'{f.LogicalName}', FILENAME = N'{snap}')"; })); return $@" CREATE DATABASE [{snapshotName}] ON {onClause} AS SNAPSHOT OF [{runDbName}]"; } private static string BuildKillAndDropDdl(string dbName) => $@" IF DB_ID(N'{dbName}') IS NOT NULL BEGIN ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{dbName}]; END"; private static string Sanitize(string label) => System.Text.RegularExpressions.Regex.Replace(label, @"[^a-zA-Z0-9_]", "_"); }