From be54effc0a6e628e0da62f1c6c7bba7acaf797f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 16:10:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test:=20=E7=B4=8D=E5=85=A5=E6=90=AC?= =?UTF-8?q?=E7=A7=BB=E5=B0=81=E5=8C=85=E9=82=8A=E7=95=8C=E8=88=87=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E5=AD=90=E7=A8=8B=E5=BA=8F=E7=B5=82=E6=AD=A2=E5=9B=9E?= =?UTF-8?q?=E6=AD=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以正式 Core 在自有 MySQL 與 MariaDB 容器驗證 8MiB 分批、64KiB 單列寫入前拒絕及三個階段的 Process.Kill。重啟預檢必須拒絕非空目標、未提交資料不可殘留,發布後另做內容校驗,避免把小封包測試或注入例外當成實際 crash 證據;不宣稱引擎斷電恢復。 --- .github/workflows/cross-platform.yml | 6 +- CHANGELOG.md | 3 + docs/LOGICAL_MIGRATION_MATRIX.md | 4 + .../MySqlMigrationRegressions.cs | 171 ++++++++++++++++++ tests/MySqlLogicalMigrationHarness/Program.cs | 14 +- tests/Run-MySqlLogicalMigration.py | 19 +- 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 2fc7c31..3f9e15a 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -112,12 +112,16 @@ jobs: docker pull postgres:16-alpine python3 tests/Run-MySqlLogicalMigration.py "$RUNNER_TEMP/mysqlpunk-logical-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + - name: Verify packet limits and actual owned process termination + if: matrix.live_tests == true + run: python3 tests/Run-MySqlLogicalMigration.py "$RUNNER_TEMP/mysqlpunk-logical-boundaries-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" boundaries + - name: Preserve synthetic logical migration evidence if: always() && matrix.live_tests == true uses: actions/upload-artifact@v4 with: name: mysql-logical-migration-evidence - path: ${{ runner.temp }}/mysqlpunk-logical-${{ github.run_id }}-${{ github.run_attempt }}/ + path: ${{ runner.temp }}/mysqlpunk-logical*-${{ github.run_id }}-${{ github.run_attempt }}/ if-no-files-found: ignore - name: Build self-contained installable archives diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a7e55..3ff1ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +#### 搬移實機回歸 +- 將 MySQL/MariaDB 的 8 MiB 分批、64 KiB 單列前置拒絕與 staging/寫入中/發布後真正子程序終止納入正式 Core harness,避免只以小資料或注入例外判斷封包與 crash 行為。 + ### 🚀 新增功能 - **MySQL/MariaDB ↔ PostgreSQL 受控邏輯搬移**:加入 signed BIGINT、LONGTEXT NO PAD binary/text C、LONGBLOB/bytea、整數單欄/複合主鍵與具名普通索引。MySQL 8.4/MariaDB 11.4 目標用本輪 staging、分批 DML 交易、schema/SHA256 校驗後單次 RENAME 發布並再次驗證,不假稱 DDL 可完整回滾。失敗/取消列出殘留物件,發布結果不明禁止盲重試,不自動刪除。其他型別、文字前綴鍵、自訂 PK 名稱與相依物件明確拒絕;仍不是任意引擎/版本無損搬移,發版繼續暫停。 - **邏輯搬移的文字、主鍵與索引子集**:SQLite STRICT ↔ PostgreSQL 保留有效 UTF-8 TEXT(BINARY/C)、明確非自增單欄/複合主鍵、普通升冪索引與欄位順序。預覽包含索引 DDL,來源變更失效、取消、缺索引或內容不符仍整批回滾;FK、其他定序、default、identity、特殊索引依舊預檢拒絕,不宣稱所有跨引擎組合完成。 diff --git a/docs/LOGICAL_MIGRATION_MATRIX.md b/docs/LOGICAL_MIGRATION_MATRIX.md index 3885fee..65ef130 100644 --- a/docs/LOGICAL_MIGRATION_MATRIX.md +++ b/docs/LOGICAL_MIGRATION_MATRIX.md @@ -34,6 +34,10 @@ 可重跑:`python3 tests/Run-MySqlLogicalMigration.py [新證據目錄]`,先快取官方 mysql:8.4、mariadb:11.4、postgres:16-alpine。直接引用正式 Core;唯一 ownership label/loopback 隨機 ports/tmpfs,finally 僅清自有容器。`migration.log`/`evidence.json` 保存實際版本、image digest、來源 hash/清理狀態,不保存資料值/密碼。CI Linux 另跑此 fixture,四平台編譯;這與既有 provider 56/56 分開。 +封包與真正子程序終止另跑 `python3 tests/Run-MySqlLogicalMigration.py [新證據目錄] boundaries`,只在自有測試容器啟動參數配置 8 MiB/64 KiB,不變更任何使用者伺服器。8 MiB 目標寫入 100 列、每列各 128 KiB binary 與 UTF8 text(共 25 MiB),確認實際多批及 schema/SHA256 往返;64 KiB 目標可搬 1007 小列+空表,128 KiB 單欄則在 DDL 前拒絕且目標 0 物件。 + +同一正式 Core harness 在 staging/未提交 batch/published 三個邊界以耐久 marker 等待自己啟動的 child,真正 `Process.Kill` 後以新連線重跑 production preview:拒絕非空目標且不改物件;未提交 batch 的 staging 沒有資料;published 保留 1007 列+空表並另作 schema/SHA256 反向搬移校驗。MySQL 8.4.11、MariaDB 11.4.13 三個邊界皆實測通過(Linux exit 137)。這僅證明 application process kill,不是 engine crash/power loss,也沒有自動清除/續寫能力。原獨立 audit-evidence 的兩份 real-crash 日誌與 snapshot 為唯讀歷史來源,本次 repo harness 可持續重跑,不以注入例外充當真 crash。 + 官方語意依據:MySQL 8.4 [implicit commit](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html)/[RENAME TABLE](https://dev.mysql.com/doc/refman/8.4/en/rename-table.html),MariaDB [implicit commit](https://mariadb.com/docs/server/reference/sql-statements/transactions/sql-statements-that-cause-an-implicit-commit)/[RENAME TABLE](https://mariadb.com/docs/server/reference/sql-statements/data-definition/rename-table)。限於受控版本與物件子集,不宣稱所有 DDL 可回滾。 ## 可重跑的 PostgreSQL 跨版本證據 diff --git a/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs b/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs new file mode 100644 index 0000000..5fc99bb --- /dev/null +++ b/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs @@ -0,0 +1,171 @@ +using System.Data.Common; +using System.Diagnostics; +using System.Reflection; +using MySqlConnector; +using Npgsql; +using MySqlPunk.Core.Models; +using MySqlPunk.Core.Services; + +internal sealed class MySqlMigrationRegressions(int pgPort, int myPort) +{ + private readonly string _password = Environment.GetEnvironmentVariable("MYSQLPUNK_TEST_PASSWORD") ?? throw new Exception("Missing fixture password."); + private ConnectionProfile Pg(string database) => new() { Provider = DatabaseProviderKind.PostgreSql, Host = "127.0.0.1", Port = pgPort, + Username = "postgres", Password = _password, Database = database, TlsMode = ConnectionTlsMode.Disabled }; + private ConnectionProfile My(string database) => new() { Provider = DatabaseProviderKind.MySql, Host = "127.0.0.1", Port = myPort, + Username = "root", Password = _password, Database = database, TlsMode = ConnectionTlsMode.Disabled }; + private DbConnection Connection(bool mysql, string database) => mysql + ? new MySqlConnection(new MySqlConnectionStringBuilder { Server = "127.0.0.1", Port = (uint)myPort, UserID = "root", Password = _password, + Database = database, SslMode = MySqlSslMode.Disabled, Pooling = false, ConnectionTimeout = 3 }.ConnectionString) + : new NpgsqlConnection(new NpgsqlConnectionStringBuilder { Host = "127.0.0.1", Port = pgPort, Username = "postgres", Password = _password, + Database = database, SslMode = SslMode.Disable, Pooling = false, Timeout = 3 }.ConnectionString); + private async Task SqlAsync(bool mysql, string database, string sql) + { + await using var connection = Connection(mysql, database); await connection.OpenAsync(); + await using var command = connection.CreateCommand(); command.CommandText = sql; + return await command.ExecuteScalarAsync(); + } + private async Task<(string Version, int Major, string Collation)> EngineAsync() + { + var version = Convert.ToString(await SqlAsync(true, "", "SELECT VERSION()"))!; + return (version, int.Parse(version.Split('.')[0]), version.Contains("MariaDB", StringComparison.Ordinal) ? "utf8mb4_nopad_bin" : "utf8mb4_0900_bin"); + } + private async Task CreateMyAsync(string database, string collation) => + await SqlAsync(true, "", "CREATE DATABASE " + database + " CHARACTER SET utf8mb4 COLLATE " + collation); + private async Task CreateSeedAsync(string database) + { + await SqlAsync(false, "postgres", "CREATE DATABASE " + database); + await SqlAsync(false, database, """ + CREATE TABLE records(id bigint NOT NULL, number bigint NOT NULL, optional bigint, note text COLLATE "C", payload bytea, + CONSTRAINT records_pkey PRIMARY KEY(id,number)); + CREATE INDEX records_optional ON records(optional ASC NULLS FIRST); + CREATE TABLE empty_table(id bigint, note text COLLATE "C", payload bytea); + INSERT INTO records SELECT number,number%2,CASE WHEN number%2=0 THEN NULL ELSE number END, + CASE WHEN number%3=0 THEN NULL ELSE '繁中😀 '||number END, + CASE WHEN number%2=0 THEN NULL ELSE decode('00ff','hex') END FROM generate_series(1,1000) number; + INSERT INTO records VALUES(-9223372036854775808,0,NULL,NULL,''::bytea),(9223372036854775807,0,NULL,'',NULL), + (-1,0,NULL,'a',''::bytea),(-2,0,NULL,'a ',NULL),(-3,0,NULL,'ABC',NULL),(-4,0,NULL,'abc',NULL),(-5,0,NULL,'é',NULL); + """); + } + private static async Task MigrateAsync(ConnectionProfile source, ConnectionProfile target, int major) + { + var preview = await LogicalMigrationService.PreviewAsync(source, target, major); + if (!preview.CanStart) throw new Exception("Preflight refused: " + string.Join(";", preview.Blockers)); + var result = await LogicalMigrationService.RunAsync(source, target, major, preview, target.Database); + if (result.Outcome != LogicalMigrationOutcome.Verified) throw new Exception(result.Report); + } + private async Task ObjectsAsync(string database) => Convert.ToInt64(await SqlAsync(true, database, + "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()")); + + public async Task RunBoundariesAsync(long packetBytes, bool crash) + { + var engine = await EngineAsync(); + if (Convert.ToInt64(await SqlAsync(true, "", "SELECT @@max_allowed_packet")) != packetBytes) throw new Exception("Wrong isolated packet configuration."); + var suffix = myPort.ToString(); + var source = "seed_" + suffix; + await CreateSeedAsync(source); + await CreateMyAsync("small", engine.Collation); + await MigrateAsync(Pg(source), My("small"), engine.Major); + await SqlAsync(false, "postgres", "CREATE DATABASE small_back_" + suffix); + await MigrateAsync(My("small"), Pg("small_back_" + suffix), 16); + if (Convert.ToInt64(await SqlAsync(true, "small", "SELECT COUNT(*) FROM records")) != 1007) throw new Exception("Small packet rows lost."); + var packetSource = "packet_" + suffix; + await SqlAsync(false, "postgres", "CREATE DATABASE " + packetSource); + await SqlAsync(false, packetSource, "CREATE TABLE records(id bigint NOT NULL PRIMARY KEY,payload bytea,note text COLLATE \"C\");" + + "INSERT INTO records SELECT number,decode(repeat('ab',131072),'hex'),repeat('😀',32768) FROM generate_series(1,100) number;"); + await CreateMyAsync("packet", engine.Collation); + var preview = await LogicalMigrationService.PreviewAsync(Pg(packetSource), My("packet"), engine.Major); + if (packetBytes == 65536) + { + if (preview.CanStart || !preview.Blockers.Any(blocker => blocker.Contains("max_allowed_packet", StringComparison.Ordinal)) || await ObjectsAsync("packet") != 0) + throw new Exception("Oversize row was not rejected before DDL."); + Console.WriteLine("PASS actual " + engine.Version + " 64KiB packet: 1007 small rows roundtrip Verified; 128KiB fields refused before DDL; target objects=0"); + } + else + { + if (!preview.CanStart) throw new Exception("Large multi-row packet preview refused."); + var batches = 0; + var result = await LogicalMigrationService.RunWithMySqlObserverAsync(Pg(packetSource), My("packet"), engine.Major, preview, CancellationToken.None, + (stage, _, _) => { if (stage == "batch") batches++; }); + if (result.Outcome != LogicalMigrationOutcome.Verified || batches < 2) throw new Exception("Byte-aware batching did not split: " + result.Report); + await SqlAsync(false, "postgres", "CREATE DATABASE packet_back_" + suffix); + await MigrateAsync(My("packet"), Pg("packet_back_" + suffix), 16); + Console.WriteLine("PASS actual " + engine.Version + " 8MiB packet: 100 x 128KiB binary plus 128KiB UTF8 text; byte-aware batches=" + batches + "; schema/SHA256 roundtrip Verified"); + } + if (crash) await RunCrashAsync(source, engine.Major, engine.Collation); + } + + public static async Task CrashChildAsync(int pgPort, int myPort, string stage, string marker, string source, string target) + { + var fixture = new MySqlMigrationRegressions(pgPort, myPort); + var engine = await fixture.EngineAsync(); + var preview = await LogicalMigrationService.PreviewAsync(fixture.Pg(source), fixture.My(target), engine.Major); + if (!preview.CanStart) throw new Exception("Child preflight refused."); + await LogicalMigrationService.RunWithMySqlObserverAsync(fixture.Pg(source), fixture.My(target), engine.Major, preview, CancellationToken.None, + (observed, _, _) => + { + if (observed != stage) return; + using var file = new FileStream(marker, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + file.WriteByte(1); file.Flush(true); + Thread.Sleep(Timeout.Infinite); + }); + throw new Exception("Crash child unexpectedly completed."); + } + + private async Task RunCrashAsync(string source, int major, string collation) + { + foreach (var stage in new[] { "staging", "batch", "published" }) + { + var database = "crash_" + stage; + await CreateMyAsync(database, collation); + var marker = Path.Combine(Path.GetTempPath(), "mysqlpunk-owned-crash-" + Guid.NewGuid().ToString("N")); + var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var argument in new[] { Assembly.GetExecutingAssembly().Location, pgPort.ToString(), myPort.ToString(), myPort.ToString(), + "crash-child", stage, marker, source, database }) start.ArgumentList.Add(argument); + using var child = Process.Start(start) ?? throw new Exception("Own child did not start."); + var stdout = child.StandardOutput.ReadToEndAsync(); var stderr = child.StandardError.ReadToEndAsync(); + try + { + var watch = Stopwatch.StartNew(); + while (!File.Exists(marker) && !child.HasExited && watch.Elapsed < TimeSpan.FromSeconds(40)) await Task.Delay(50); + if (!File.Exists(marker) || child.HasExited) throw new Exception("Own child did not reach " + stage); + var expectedObjects = stage == "staging" ? 1 : 2; + if (await ObjectsAsync(database) != expectedObjects) throw new Exception("Wrong catalog at kill boundary."); + child.Kill(); await child.WaitForExitAsync(); + if (child.ExitCode == 0) throw new Exception("Child was not terminated."); + var preview = await LogicalMigrationService.PreviewAsync(Pg(source), My(database), major); + var staging = Convert.ToInt64(await SqlAsync(true, database, + "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,4)='_mp_'")); + if (preview.CanStart || await ObjectsAsync(database) != expectedObjects || staging != (stage == "published" ? 0 : expectedObjects)) + throw new Exception("Restart did not refuse unchanged nonempty target."); + if (stage == "batch") + { + await using var connection = Connection(true, database); await connection.OpenAsync(); + var names = new List(); + await using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()"; + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) names.Add(reader.GetString(0)); + } + foreach (var name in names) + if (Convert.ToInt64(await SqlAsync(true, database, "SELECT COUNT(*) FROM `" + name + "`")) != 0) throw new Exception("Killed uncommitted DML retained rows."); + } + if (stage == "published") + { + await SqlAsync(false, "postgres", "CREATE DATABASE crash_back_" + myPort); + await MigrateAsync(My(database), Pg("crash_back_" + myPort), 16); + if (Convert.ToInt64(await SqlAsync(true, database, "SELECT COUNT(*) FROM records")) != 1007) throw new Exception("Published rows lost."); + } + Console.WriteLine("PASS actual owned child Process.Kill at " + stage + "; exit=" + child.ExitCode + "; objects=" + expectedObjects + + "; staging=" + staging + "; fresh production preview refuses without writes" + (stage == "published" ? "; 1007 rows schema/SHA256 verified" : "")); + } + finally + { + if (!child.HasExited) { child.Kill(); await child.WaitForExitAsync(); } + await stdout; await stderr; + if (File.Exists(marker)) File.Delete(marker); + } + } + Console.WriteLine("PASS process kill only; engine crash/power loss untested"); + } +} diff --git a/tests/MySqlLogicalMigrationHarness/Program.cs b/tests/MySqlLogicalMigrationHarness/Program.cs index e8f8b2d..5d2e080 100644 --- a/tests/MySqlLogicalMigrationHarness/Program.cs +++ b/tests/MySqlLogicalMigrationHarness/Program.cs @@ -6,6 +6,11 @@ var password = Environment.GetEnvironmentVariable("MYSQLPUNK_TEST_PASSWORD") ?? throw new Exception("Missing fixture password."); var pgPort = int.Parse(args[0]); var myPorts = new[] { int.Parse(args[1]), int.Parse(args[2]) }; +if (args.Length > 3 && args[3] == "crash-child") +{ + await MySqlMigrationRegressions.CrashChildAsync(pgPort, myPorts[0], args[4], args[5], args[6], args[7]); + return; +} string PgConnection(string database) => new NpgsqlConnectionStringBuilder { Host = "127.0.0.1", Port = pgPort, Username = "postgres", Password = password, @@ -25,7 +30,14 @@ await using var connection = new MySqlConnection(MyConnection(port)); await connection.OpenAsync(); Console.WriteLine("Actual MySQL family=" + connection.ServerVersion); } -if (args.Length > 3) return; +if (args.Contains("probe")) return; +if (args.Length > 3 && args[3] == "boundaries") +{ + foreach (var port in myPorts) await new MySqlMigrationRegressions(pgPort, port).RunBoundariesAsync(8388608, true); + foreach (var port in new[] { int.Parse(args[4]), int.Parse(args[5]) }) + await new MySqlMigrationRegressions(pgPort, port).RunBoundariesAsync(65536, false); + return; +} async Task PgSql(string database, string sql) { await using var connection = new NpgsqlConnection(PgConnection(database)); await connection.OpenAsync(); diff --git a/tests/Run-MySqlLogicalMigration.py b/tests/Run-MySqlLogicalMigration.py index fbea897..149ea17 100644 --- a/tests/Run-MySqlLogicalMigration.py +++ b/tests/Run-MySqlLogicalMigration.py @@ -11,6 +11,9 @@ root = Path(__file__).resolve().parents[1] output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/tmp') / ('mysqlpunk-my-pg-' + uuid.uuid4().hex) output.mkdir(parents=True, exist_ok=False) +mode = sys.argv[2] if len(sys.argv) > 2 else 'regular' +if mode not in ('regular', 'boundaries'): + raise ValueError('Unknown fixture mode') project = root / 'tests/MySqlLogicalMigrationHarness/Review.csproj' subprocess.run(['dotnet', 'build', str(project), '-c', 'Release'], check=True) environment = os.environ.copy() @@ -31,19 +34,25 @@ def docker(*arguments): try: ports = [] - for index, (tag, port, directory, variable) in enumerate([ + engines = [ ('postgres:16-alpine', 5432, '/var/lib/postgresql/data', 'POSTGRES_PASSWORD'), ('mysql:8.4', 3306, '/var/lib/mysql', 'MYSQL_ROOT_PASSWORD'), ('mariadb:11.4', 3306, '/var/lib/mysql', 'MARIADB_ROOT_PASSWORD') - ]): + ] + if mode == 'boundaries': + engines += [('mysql:8.4', 3306, '/var/lib/mysql', 'MYSQL_ROOT_PASSWORD'), + ('mariadb:11.4', 3306, '/var/lib/mysql', 'MARIADB_ROOT_PASSWORD')] + for index, (tag, port, directory, variable) in enumerate(engines): image = json.loads(docker('image', 'inspect', tag, '--format', '{{json .}}')) + options = [] if port == 5432 else ['--max-allowed-packet=' + ('65536' if index > 2 else '8388608')] container = docker('run', '-d', '--name', key + '-' + str(index), '--label', 'mysqlpunk.test.owner=' + key, '--cpus=1', '--memory=1g', '--pids-limit=512', '--mount', 'type=tmpfs,destination=' + directory + ',tmpfs-size=536870912', - '-e', variable, '-p', '127.0.0.1::' + str(port), image['Id']) + '-e', variable, '-p', '127.0.0.1::' + str(port), image['Id'], *options) containers.append(container) ports.append(docker('port', container, str(port) + '/tcp').split(':')[-1]) metadata['images'].append({'tag': tag, 'id': image['Id'], 'digests': image.get('RepoDigests', [])}) - command = ['dotnet', str(project.parent / 'bin/Release/net8.0/MySqlPunk.MySqlLogicalMigrationHarness.dll'), *ports] + arguments = ports[:3] + (['boundaries', *ports[3:]] if mode == 'boundaries' else []) + command = ['dotnet', str(project.parent / 'bin/Release/net8.0/MySqlPunk.MySqlLogicalMigrationHarness.dll'), *arguments] for attempt in range(60): probe = subprocess.run(command + ['probe'], env=environment, text=True, capture_output=True, timeout=20) if probe.returncode == 0: @@ -52,7 +61,7 @@ def docker(*arguments): time.sleep(2) else: raise RuntimeError('Isolated engines not ready') - result = subprocess.run(command, env=environment, text=True, capture_output=True, timeout=240) + result = subprocess.run(command, env=environment, text=True, capture_output=True, timeout=480) text = (result.stdout + '\n' + result.stderr).replace(password, '[redacted]') (output / 'migration.log').write_text(text) print(text, flush=True) From dbafdd6de15fd38745f2b41953bdf15863b568fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 16:29:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8F=B4=20MySQL=20?= =?UTF-8?q?=E8=88=87=20MariaDB=20=E5=90=8C=E5=BC=95=E6=93=8E=E6=96=B0?= =?UTF-8?q?=E8=88=8A=E7=89=88=E5=8F=97=E6=8E=A7=E6=90=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沿用來源鎖定、專屬 staging 與結構及內容校驗,補上 MySQL 8.0/8.4、MariaDB 10.11/11.4 雙向搬移,避免只支援換引擎卻無法搬回舊版。核對完整版本與官方引擎標記,修正 PRIMARY 與表內索引名稱的方向檢查;未知 fork、跨家族與無法保留的語意仍在寫入前拒絕。 加入正式 Core 容器往返、殘留禁止重試與來源快照失效回歸,更新 UI 選項及實測矩陣。Release build、33/33 smoke、Avalonia handlers 和兩組新舊版、封包、真正子程序終止及既有搬移回歸通過;本機 provider 全套因 TiDB 映像下載失敗未完成,由新 head CI 補驗。版本與發版維持暫停。 --- .github/workflows/cross-platform.yml | 7 ++ CHANGELOG.md | 5 +- README.md | 4 +- docs/FEATURE_NOTES.md | 6 ++ docs/LOGICAL_MIGRATION_MATRIX.md | 18 +++-- .../Services/LogicalMigrationMySql.cs | 18 +++-- .../Services/LogicalMigrationService.cs | 5 +- .../LogicalMigrationTests.cs | 8 +++ mySQLPunk.Desktop/LogicalMigrationWindow.cs | 6 +- .../DesktopProfileOperationHarness/Program.cs | 4 +- .../MySqlMigrationRegressions.cs | 69 +++++++++++++++++++ tests/MySqlLogicalMigrationHarness/Program.cs | 13 +++- tests/Run-MySqlLogicalMigration.py | 9 ++- 13 files changed, 145 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 3f9e15a..af15290 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -116,6 +116,13 @@ jobs: if: matrix.live_tests == true run: python3 tests/Run-MySqlLogicalMigration.py "$RUNNER_TEMP/mysqlpunk-logical-boundaries-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" boundaries + - name: Verify MySQL 8.0 to 8.4 and MariaDB 10.11 to 11.4 both directions + if: matrix.live_tests == true + run: | + docker pull mysql:8.0 + docker pull mariadb:10.11 + python3 tests/Run-MySqlLogicalMigration.py "$RUNNER_TEMP/mysqlpunk-logical-versions-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" versions + - name: Preserve synthetic logical migration evidence if: always() && matrix.live_tests == true uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff1ffc..58b0f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,8 @@ ## [Unreleased] -#### 搬移實機回歸 -- 將 MySQL/MariaDB 的 8 MiB 分批、64 KiB 單列前置拒絕與 staging/寫入中/發布後真正子程序終止納入正式 Core harness,避免只以小資料或注入例外判斷封包與 crash 行為。 - ### 🚀 新增功能 +- **MySQL/MariaDB 同引擎新舊版邏輯搬移**:開放 MySQL Community 8.0 ↔ 8.4、MariaDB 官方 binary 10.11 ↔ 11.4 的既有 BIGINT/LONGTEXT/LONGBLOB、整數主鍵及普通索引子集。核對完整版本與引擎標記,保留 MySQL 固定 PRIMARY 和表內索引名稱;仍用專屬 staging、內容校驗及單次發布,不允許 MySQL ↔ MariaDB 或未知 fork。版本選項與預覽一致,發版繼續暫停。 - **MySQL/MariaDB ↔ PostgreSQL 受控邏輯搬移**:加入 signed BIGINT、LONGTEXT NO PAD binary/text C、LONGBLOB/bytea、整數單欄/複合主鍵與具名普通索引。MySQL 8.4/MariaDB 11.4 目標用本輪 staging、分批 DML 交易、schema/SHA256 校驗後單次 RENAME 發布並再次驗證,不假稱 DDL 可完整回滾。失敗/取消列出殘留物件,發布結果不明禁止盲重試,不自動刪除。其他型別、文字前綴鍵、自訂 PK 名稱與相依物件明確拒絕;仍不是任意引擎/版本無損搬移,發版繼續暫停。 - **邏輯搬移的文字、主鍵與索引子集**:SQLite STRICT ↔ PostgreSQL 保留有效 UTF-8 TEXT(BINARY/C)、明確非自增單欄/複合主鍵、普通升冪索引與欄位順序。預覽包含索引 DDL,來源變更失效、取消、缺索引或內容不符仍整批回滾;FK、其他定序、default、identity、特殊索引依舊預檢拒絕,不宣稱所有跨引擎組合完成。 - **可驗證邏輯搬移的第一階段**:SQL Server 專用相容工具加入受限雙向跨版本;Avalonia 新增 SQLite STRICT ↔ PostgreSQL 的整數/二進位搬移,預覽來源/目標版本、schema/data 轉換與阻擋原因,確認後才寫入另建空目標。單一交易、分批、內容校驗與不確定狀態禁止重試,不將一般資料傳輸或原生備份宣稱為無限制跨版本/跨引擎還原。 @@ -58,6 +56,7 @@ - **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。 ### 🛠️ 問題修正與優化 +- 將 MySQL/MariaDB 的 8 MiB 分批、64 KiB 單列前置拒絕與 staging/寫入中/發布後真正子程序終止納入正式 Core harness,避免只以小資料或注入例外判斷封包與 crash 行為。 - PostgreSQL 邏輯搬移保留並校驗主鍵 constraint/索引名稱;跨 SQLite 無法保留的自訂名稱與 PostgreSQL 目標名稱碰撞改在預檢阻擋,避免已驗證卻靜默更名。 - **PostgreSQL 索引鍵預檢**:讀取實際 block_size,標準 8 KiB page 下逐列檢查 PK 與普通索引的保守 2048-byte tuple 預算,包含 UTF-8、複合鍵與對齊成本。超限在確認前拒絕,不依賴壓縮或等 INSERT 才失敗;未索引文字仍保留原欄位限額。 - **SQL Server 搬移參數**:TimeSpan 不再被 DbType.Time 轉成 DateTime,NULL time 明確保留 SQL time;binary 欄位的 NULL 不再推導成 nvarchar。每批資料參數改為 2098,保留 RPC 額度。新增正式來源 SQL Server 2019 ↔ 2022 隔離雙向回歸與末批失敗/安全重試測試,不宣稱 PK/FK/index/identity 搬移或所有版本已驗證。 diff --git a/README.md b/README.md index 534023a..baec965 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ Windows 完整版介面支援繁體中文與英文;資料庫密碼、SSH 密 未發版的可驗證邏輯搬移:Windows SQL Server 專用工具支援 2016–2025 受限雙向跨版本;Avalonia「邏輯搬移」提供 SQLite STRICT ↔ PostgreSQL 的 INTEGER/bigint、BLOB/bytea、UTF-8 TEXT(BINARY/C)、非自增單欄/複合主鍵及普通升冪索引子集,使用單一交易與結構/內容校驗。 -另已加入 MySQL 8.4/MariaDB 11.4 ↔ PostgreSQL 的 signed BIGINT、LONGBLOB/bytea、LONGTEXT utf8mb4 NO PAD binary/text C 子集,主鍵與普通索引只允許整數欄。MySQL 目標先建立本輪專屬 staging、交易分批寫入/校驗,再單次 RENAME 發布並重新校驗;DDL 隱含提交,失敗可能留下 staging,發布結果不明時禁止盲重試,不自動刪除或聲稱完整回滾。 +另已加入 MySQL Community 8.0/8.4、MariaDB 官方 binary 10.11/11.4 ↔ PostgreSQL,以及 MySQL 8.0 ↔ 8.4、MariaDB 10.11 ↔ 11.4 同引擎新舊版的 signed BIGINT、LONGBLOB/bytea、LONGTEXT utf8mb4 NO PAD binary/text C 子集,主鍵與普通索引只允許整數欄。MySQL 目標先建立本輪專屬 staging、交易分批寫入/校驗,再單次 RENAME 發布並重新校驗;DDL 隱含提交,失敗可能留下 staging,發布結果不明時禁止盲重試,不自動刪除或聲稱完整回滾。 -所有路徑只接受另建空目標與明確確認;FK、default、identity、其他定序、時間與未忠實轉換的功能在預檢阻擋。PG 自訂主鍵名稱往 MySQL、MySQL 配對的文字/binary 索引鍵等限制另見矩陣。實機證據限 PG16.15 ↔ MySQL8.4.11/MariaDB11.4.13、PG17.10 ↔ 14.24/16.15 及各自的合成 fixture,不代表全部版本或 schema。其他 MySQL/MariaDB 配對與 SQL Server 跨引擎轉換仍未開放,不宣稱整體完成或任意引擎/版本的原生 restore;版本與發布維持 hold。支援方向與實機/mock 證據區分見 [搬移矩陣](docs/LOGICAL_MIGRATION_MATRIX.md)。 +所有路徑只接受另建空目標與明確確認;FK、default、identity、其他定序、時間與未忠實轉換的功能在預檢阻擋。PG 自訂主鍵名稱往 MySQL、MySQL 配對的文字/binary 索引鍵等限制另見矩陣。實機證據限 PG16.15 ↔ MySQL8.4.11/MariaDB11.4.13、MySQL8.0.46 ↔ 8.4.11、MariaDB10.11.19 ↔ 11.4.13、PG17.10 ↔ 14.24/16.15 及各自的合成 fixture,不代表全部版本或 schema。MySQL ↔ MariaDB、其他 MySQL/MariaDB 配對與 SQL Server 跨引擎轉換仍未開放,不宣稱整體完成或任意引擎/版本的原生 restore;版本與發布維持 hold。支援方向與實機/mock 證據區分見 [搬移矩陣](docs/LOGICAL_MIGRATION_MATRIX.md)。 目前的 `v1.0.0.22` GitHub Release 同時提供 Windows x64 完整版 setup、self-contained Linux x64/ARM64 安裝壓縮檔,以及 macOS Intel/Apple Silicon `.app.zip`。Linux 壓縮檔內附使用者層級的 `install.sh`/`uninstall.sh`;macOS 預覽目前採 ad-hoc 簽署、尚未 Apple notarize。每個跨平台資產都有獨立 `.sha256` 可驗證完整性。完整變更請見 `CHANGELOG.md`。 diff --git a/docs/FEATURE_NOTES.md b/docs/FEATURE_NOTES.md index 19c2304..15c0cab 100644 --- a/docs/FEATURE_NOTES.md +++ b/docs/FEATURE_NOTES.md @@ -4,6 +4,12 @@ ## 未完成功能與已知限制 +- **MySQL/MariaDB 同引擎新舊版搬移(受控子集)** + - MySQL Community 8.0.x/8.4.x、MariaDB 官方 binary 10.11.x/11.4.x 同家族搬移沿用來源鎖定、完整 catalog/DDL 檢查、byte-aware batching、專屬 staging、schema/SHA256 校驗與單次 RENAME;沒有把 DDL 隱含提交改稱完整交易回滾。 + - 實際 8.0.46 ↔ 8.4.11、10.11.19 ↔ 11.4.13 各 1007 列+空表,含整數複合 PRIMARY、nullable bigint 普通索引、UTF8/emoji/NULL/空 text/binary 及 Int64 極值;正式 harness 另測 stale source 在 DDL 前拒絕、殘留阻止重試、跨家族拒絕與表內重複索引名稱。不是所有 patch/型別/schema 的證明。 + - UI MySQL 8.x 明列僅 8.0/8.4,預覽完整實際版本;MariaDB 10.11/11.4 分別選取。API 另核對 VERSION 與 version_comment,拒絕未知 fork,不只相信 protocol handshake 的版本前綴。 + - `Run-MySqlLogicalMigration.py` 的 versions/boundaries 模式連結正式 Core,保存映像 digest、來源 hash、版本與自有容器清理證據。boundaries 以真 Process.Kill 測 staging/未提交 batch/published,並驗證 8 MiB 多批、64 KiB 前置拒絕;不是 engine crash/power loss。Windows LocalDB 匯出 integration 本輪未在 Linux 執行,歷史 crash 記錄不當本輪重跑。完整方向/限制見 `docs/LOGICAL_MIGRATION_MATRIX.md`;release hold 不變。 + - **Windows 更新流程** - 下載只接受 Windows x64 可攜包或安裝檔;SHA-256 可來自 GitHub asset digest 或舊版 manifest,缺少有效校驗碼就停止。檔案先下載到獨立暫存檔,驗證通過才取代既有下載;取消、網路錯誤與校驗失敗都會清理暫存檔。 - 靜默安裝明確指定目前程式目錄與使用者層級,支援空白、中文及單引號路徑。兩種更新腳本都等待目前程式結束,兩分鐘逾時後直接停止;安裝成功或失敗都嘗試重新啟動現有程式,並傳回安裝結果。 diff --git a/docs/LOGICAL_MIGRATION_MATRIX.md b/docs/LOGICAL_MIGRATION_MATRIX.md index 65ef130..a632be3 100644 --- a/docs/LOGICAL_MIGRATION_MATRIX.md +++ b/docs/LOGICAL_MIGRATION_MATRIX.md @@ -11,8 +11,16 @@ | SQLite → PostgreSQL | SQLite 3.37–3.53 → PostgreSQL 14–17 | INTEGER → bigint、BLOB → bytea、TEXT BINARY → text C;非自增 PK、普通索引 | 隔離 CI PostgreSQL 16 fixture 加入文字/鍵/索引雙向 round-trip;只以這次新 head 結果為證據 | | PostgreSQL → SQLite | PostgreSQL 14–17 → SQLite 3.37–3.53 | 上述型別反向映射;PK → WITHOUT ROWID、普通索引 | PG17 實際 roundtrip 與既有 PG16 fixture;PG15 未實機跑 | | PostgreSQL → PostgreSQL | 14–17 雙向及同版本 | 同一 bigint/bytea/text C、PK/索引子集與全部預檢 | 真實 17.10 ↔ 14.24、17.10 ↔ 16.15;不是全部版本配對驗證 | -| MySQL/MariaDB → PostgreSQL | MySQL 8.4.x/MariaDB 11.4.x → PG14–17 | signed BIGINT/LONGTEXT NO PAD binary/LONGBLOB,整數 PK/普通索引;PG 目標單交易 | 正式路徑實際 8.4.11 → PG16.15、11.4.13 → PG16.15,各 1007 列+空表;其他 PG 版本配對未跑 | -| PostgreSQL → MySQL/MariaDB | PG14–17 → MySQL 8.4.x/MariaDB 11.4.x | 上述反向映射,專屬 staging+校驗+單一 multi-table RENAME+發布後校驗 | 正式路徑實際 PG16.15 → 8.4.11/11.4.13;不是全版本、全型別或一般 DDL atomic 保證 | +| MySQL/MariaDB → PostgreSQL | MySQL Community 8.0.x/8.4.x、MariaDB 官方 binary 10.11.x/11.4.x → PG14–17 | signed BIGINT/LONGTEXT NO PAD binary/LONGBLOB,整數 PK/普通索引;PG 目標單交易 | 正式路徑實際 8.4.11/11.4.13 → PG16.15,各 1007 列+空表;8.0.46/10.11.19 另經同引擎往返後回 PG16.15,不是舊版直接 PG 全配對證據 | +| PostgreSQL → MySQL/MariaDB | PG14–17 → 上述四個 MySQL/MariaDB 系列 | 上述反向映射,專屬 staging+校驗+單一 multi-table RENAME+發布後校驗 | PG16.15 → 8.0.46/8.4.11/10.11.19/11.4.13;不是全版本、全型別或一般 DDL atomic 保證 | +| MySQL → MySQL | Community 8.0.x/8.4.x 雙向及同系列 | 同一受控子集,保留固定 PRIMARY 與表內普通索引名稱;專屬 staging 發布 | 正式 8.0.46 → 8.4.11 → 8.0.46,1007 列+空表;不是每個 patch 或 engine crash 保證 | +| MariaDB → MariaDB | 官方 binary 10.11.x/11.4.x 雙向及同系列 | 同上,不與 MySQL 互換 | 正式 10.11.19 → 11.4.13 → 10.11.19,1007 列+空表;不是每個 patch 或 engine crash 保證 | + +## 第四階段:最後兩組同引擎新舊版 + +同引擎路徑沿用下述受控來源快照與 staging 策略,不直接 restore。來源與目標均核對 `SELECT VERSION()` 與 `@@version_comment`:只接受 MySQL Community 的 `MySQL Community Server - GPL` 或 MariaDB 的 `mariadb.org binary distribution`,未知 fork/未驗證發行包/其他版本拒絕。MySQL UI 明列 8.x 僅 8.0/8.4,預览保留完整版本而不把同 major 當 minor 相同;MariaDB 10.11/11.4 分別對應 major 10/11。MariaDB 的 protocol handshake 前綴不作版本證據。 + +可重跑:`python3 tests/Run-MySqlLogicalMigration.py [新證據目錄] versions`;需先快取上述四個官方映像及 PG16。每組 PG seed → 舊版 → 新版 → 空舊版 → PG,1007 列+空表、整數複合 PRIMARY、nullable bigint 普通索引、UTF8/emoji/NULL/空文字與 bytes/Int64 極值,逐段 production schema/SHA256 驗證,另實際確認 duplicate PK 被拒絕、具名索引存在。正式路徑也測 batch fault 殘留/禁止重試、同列數來源變更在 DDL 前拒絕、選錯 major/自身/MySQL↔MariaDB 拒絕,以及表內同名 index 保留、往 PG 全域名稱碰撞拒絕。`evidence.json` 留實際版本、映像 digest、來源 hash 與清理結果;不擴張為全部版本組合。 ## 第三階段:MySQL/MariaDB ↔ PostgreSQL @@ -24,7 +32,7 @@ 4. staging catalog/結構/逐欄內容 SHA256 全部吻合、DML 提交後再檢查 staging 與來源,才送出**一個** multi-table RENAME。只限上述版本的 InnoDB 普通表、無 FK/trigger 等相依物件,且不覆蓋名稱。碰撞會失敗,不先刪除目標;發布後再次校驗才 `Verified`。 5. staging 之前拒絕為 `Refused`。CREATE 曾送出後失敗/取消為 `StagingRemains`,列出本輪 staging→原名與階段;DML 可回滾,但表不會消失,列數只是處理進度。RENAME 曾送出後任何失敗/ack lost 為 `Uncertain`,不推測已發布或已撤銷。兩者禁止 UI 續寫/盲重試,不自動 DROP;Task 與 parent gate 保持到實際操作停止。RENAME 送出後取消不撤銷已提交操作,需等發布/驗證結果。 -沒有逐表 checkpoint、自動 cleanup 或 durable 客戶端 resume journal。程序/引擎 crash 不在本輪實際驗證範圍;重啟後任何 staging 或發布物件都讓「必須空目標」預檢拒絕,需 DBA 核對實際 catalog 與本輪報告,不把名稱前綴當成自動刪除授權。不得把正常 RENAME 碰撞探測/注入 ack lost 擴大為實測斷電 crash recovery。 +沒有逐表 checkpoint、自動 cleanup 或 durable 客戶端 resume journal。本輪另以真正 application Process.Kill 驗證下述三個邊界;引擎 crash/斷電未測。重啟後任何 staging 或發布物件都讓「必須空目標」預檢拒絕,需 DBA 核對實際 catalog 與本輪報告,不把名稱前綴當成自動刪除授權。不得把正常 RENAME 碰撞探測/注入 ack lost 擴大為實測斷電 crash recovery。 精確子集:有/無整數單欄或複合 PK(手動給值、最多 16 欄)、具名普通升冪 bigint 索引;BIGINT signed ↔ bigint、LONGTEXT ↔ text C、LONGBLOB ↔ bytea、NULL/NOT NULL。表/欄/索引名只接受小寫 ASCII 63 字元,索引在來源與目標名稱空間不得碰撞。MySQL `PRIMARY` 映射 PG 預設 `表名前58字元_pkey`;PG 自訂 PK 名稱無法保留為 MySQL PRIMARY,預檢拒絕。映射依來源引擎判斷:PG 自訂名稱恰為 `PRIMARY` 仍是明確名稱,PG→PG 保留並實測 `ON CONFLICT ON CONSTRAINT "PRIMARY"`,不誤當 MySQL 內建名稱。恰巧符合預設慣例的手動 PG 名稱無法由 catalog 區分;如依賴該 constraint 名稱,應用 PG 目標,不承諾跨引擎 ON CONFLICT 或字串函式行為相同。 @@ -90,12 +98,12 @@ SQLite/PostgreSQL 工具入口在 Avalonia 工作台「邏輯搬移」。來 | 方向 | 現況/執行前拒絕的技術理由 | 下一階段必要能力(不是本輪已通過) | | --- | --- | --- | | MySQL/MariaDB → SQLite/SQLServer | unsigned、zero date、ENUM/SET、charset、PAD SPACE、AUTO_INCREMENT 等仍無等價映射;與 PG 的受控子集已落地 | 另訂目的端 schema/資料 codec 與語意驗證,不把 count-only transfer 當忠實轉換 | -| SQLite/SQLServer → MySQL/MariaDB,以及 MySQL↔MariaDB/跨版本 | 不開放這些配對;與 PG 的 staging 路徑不等於其他來源語意已可保留 | 依目的端型別/定序契約與引擎版本驗證擴充;不直接沿用既有對映或自動刪 staging | +| SQLite/SQLServer → MySQL/MariaDB,以及 MySQL↔MariaDB/其他版本 | 不開放這些配對;已開放的兩組同引擎版本與 PG staging 路徑不等於其他來源語意已可保留 | 依目的端型別/定序契約與引擎版本驗證擴充;不直接沿用既有對映或自動刪 staging | | SQLServer → SQLite/PG/MySQL,以及反向 | UTF-16/UTF-8、linguistic/BIN2 定序的比較/尾空白規則、rowversion、decimal/time scale、identity/default/sequence 不能把現有型別名稱替換當成保留語意 | 可重用 SQLServer catalog 的阻擋規則,但需跨 provider canonical schema/資料 codec、來源一致快照與逐欄語意/版本證據;目前只 Windows SQLServer→SQLServer 專用子集可執行 | | Oracle/Snowflake 與其他引擎 | NUMBER/時間、空字串 NULL、identifier/collation 與交易 DDL 不等價 | 專用 catalog/codec/交易恢復設計;本輪不開連線 | | MongoDB、Redis/Garnet 與表格引擎 | document/key-value model、TTL/原子操作/巢狀內容沒有通用無損表格映射 | 明確專用模型轉換契約,不當成同義 restore | -上述其他方向服務在開連線前拒絕。雙向跨版本/換引擎的整體需求仍未全部完成;目前已擴充 SQLite/PG 與 MySQL/MariaDB↔PG 的可驗證子集,不是全引擎完成或發版宣告。release 仍 hold。 +SQLite/SQLServer 與 MySQL 配對、非關聯式等方向在開連線前拒絕;MySQL↔MariaDB、未知 fork/版本須讀實際 engine catalog 後拒絕,仍在任何目標 DDL 前停止。雙向跨版本/換引擎的整體需求仍未全部完成;目前已擴充 SQLite/PG、MySQL/MariaDB↔PG 與上述兩組同引擎版本的可驗證子集,不是全引擎完成或發版宣告。release 仍 hold。 有 FK、其他字串定序、時間、decimal、identity、procedure/trigger 的一般資料庫仍須使用引擎官方 schema/data migration 工具與人工轉換/驗證,不是宣稱 bacpac 可無限制降版。新版 SQL Server .bak 仍不能直接還原到舊引擎,compatibility level 不改備份格式;既有原生 restore 預檢繼續保留。 diff --git a/mySQLPunk.Core/Services/LogicalMigrationMySql.cs b/mySQLPunk.Core/Services/LogicalMigrationMySql.cs index 0460d60..8656165 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationMySql.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationMySql.cs @@ -26,11 +26,17 @@ internal static Task RunWithMySqlObserverAsync(Connectio private static string MySqlQuote(string name) => "`" + name.Replace("`", "``") + "`"; private static bool IsMariaDb(string version) => version.Contains("MariaDB", StringComparison.Ordinal); private static string MySqlCollation(string version) => IsMariaDb(version) ? "utf8mb4_nopad_bin" : "utf8mb4_0900_bin"; + internal static bool SupportedMySqlVersion(string version) => IsMariaDb(version) + ? Regex.IsMatch(version, "\\A(?:10\\.11|11\\.4)\\.[0-9]+-MariaDB(?:-[A-Za-z0-9.+~_-]+)?\\z", RegexOptions.CultureInvariant) + : Regex.IsMatch(version, "\\A8\\.(?:0|4)\\.[0-9]+\\z", RegexOptions.CultureInvariant); + internal static bool SupportedMySqlIdentity(string version, string comment) => SupportedMySqlVersion(version) && + comment == (IsMariaDb(version) ? "mariadb.org binary distribution" : "MySQL Community Server - GPL"); private static void ValidateMySqlDirection(ConnectionProfile source, ConnectionProfile target, Snapshot origin, Snapshot destination, List blockers) { - if (source.Provider != DatabaseProviderKind.PostgreSql && target.Provider != DatabaseProviderKind.PostgreSql) - blockers.Add("MySQL/MariaDB 本階段只允許與 PostgreSQL 雙向邏輯搬移,不開放其他配對。"); + if (source.Provider == DatabaseProviderKind.MySql && target.Provider == DatabaseProviderKind.MySql && + IsMariaDb(origin.Version) != IsMariaDb(destination.Version)) + blockers.Add("只允許 MySQL 8.0 ↔ 8.4 或 MariaDB 10.11 ↔ 11.4 的同引擎搬移;MySQL ↔ MariaDB 不開放。"); if (origin.Tables.Count > 100) blockers.Add("MySQL 配對最多 100 表,限制單次 staging 與 RENAME 語句規模。"); if (target.Provider == DatabaseProviderKind.MySql && (destination.PacketBytes < 65536 || origin.Data.Values.SelectMany(rows => rows).Any(row => MySqlRowBytes(row) + 16384 > MySqlBatchBytes(destination.PacketBytes)))) @@ -43,10 +49,10 @@ private static void ValidateMySqlDirection(ConnectionProfile source, ConnectionP var keys = table.Indexes.Select(index => index.Columns).Concat(table.PrimaryKey.Count == 0 ? Array.Empty>() : new[] { table.PrimaryKey }); if (keys.Any(key => key.Count is < 1 or > 16 || key.Any(name => table.Columns.All(column => column.Name != name || column.Kind != "integer")))) blockers.Add(table.Name + ":MySQL 配對只保留最多 16 欄 signed bigint 主鍵/普通索引;TEXT/BLOB 前綴索引不能忠實替代完整鍵。"); - if (target.Provider == DatabaseProviderKind.MySql && table.PrimaryKeyName != null && table.PrimaryKeyName != DefaultPrimaryKeyName(table.Name)) + if (source.Provider == DatabaseProviderKind.PostgreSql && target.Provider == DatabaseProviderKind.MySql && table.PrimaryKeyName != null && table.PrimaryKeyName != DefaultPrimaryKeyName(table.Name)) blockers.Add(table.Name + ":MySQL 的 PRIMARY 無法保留自訂 PostgreSQL constraint 名稱。"); } - if (origin.Tables.SelectMany(table => table.Indexes).Select(index => index.Name).GroupBy(name => name, StringComparer.Ordinal).Any(group => group.Count() > 1)) + if (target.Provider == DatabaseProviderKind.PostgreSql && origin.Tables.SelectMany(table => table.Indexes).Select(index => index.Name).GroupBy(name => name, StringComparer.Ordinal).Any(group => group.Count() > 1)) blockers.Add("MySQL 表內索引名稱在 PostgreSQL 全域名稱空間重複,不能靜默更名。"); } @@ -63,8 +69,8 @@ private static async Task ReadMySqlSnapshotAsync(DbConnection connecti var blockers = new List(); var version = Convert.ToString(await ScalarAsync(connection, transaction, "SELECT VERSION();", token)) ?? ""; var packetBytes = Convert.ToInt64(await ScalarAsync(connection, transaction, "SELECT @@max_allowed_packet;", token)); - var validVersion = IsMariaDb(version) ? Regex.IsMatch(version, "\\A11\\.4\\.[0-9]+-MariaDB") : Regex.IsMatch(version, "\\A8\\.4\\.[0-9]+\\z"); - if (!validVersion) blockers.Add("只驗證 MySQL 8.4.x/MariaDB 11.4.x;其他相容伺服器或版本不放行。"); + var versionComment = Convert.ToString(await ScalarAsync(connection, transaction, "SELECT @@version_comment;", token)) ?? ""; + if (!SupportedMySqlIdentity(version, versionComment)) blockers.Add("只驗證 MySQL Community 8.0.x/8.4.x、MariaDB 官方 binary 10.11.x/11.4.x;VERSION/version_comment 不符、其他 fork 或版本不放行。"); var collation = MySqlCollation(version); if (Convert.ToInt64(await ScalarAsync(connection, transaction, "SELECT @@lower_case_table_names;", token)) != 0) blockers.Add("要求 lower_case_table_names=0;不更改伺服器設定。"); diff --git a/mySQLPunk.Core/Services/LogicalMigrationService.cs b/mySQLPunk.Core/Services/LogicalMigrationService.cs index df81781..cd52ad6 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationService.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationService.cs @@ -40,7 +40,8 @@ private static bool Supported(ConnectionProfile profile) => private static bool SupportedPair(ConnectionProfile source, ConnectionProfile target) => Supported(source) && Supported(target) && (source.Provider != DatabaseProviderKind.MySql && target.Provider != DatabaseProviderKind.MySql || source.Provider == DatabaseProviderKind.MySql && target.Provider == DatabaseProviderKind.PostgreSql || - source.Provider == DatabaseProviderKind.PostgreSql && target.Provider == DatabaseProviderKind.MySql); + source.Provider == DatabaseProviderKind.PostgreSql && target.Provider == DatabaseProviderKind.MySql || + source.Provider == DatabaseProviderKind.MySql && target.Provider == DatabaseProviderKind.MySql); private static LogicalMigrationPreview Refused(ConnectionProfile target, string reason) => new("未識別", "未識別", target.Database, Array.Empty(), Array.Empty(), new[] { reason }, ""); @@ -49,7 +50,7 @@ private static async Task PreviewCoreAsync(ConnectionPr int selectedTargetMajor, CancellationToken token) { if (!SupportedPair(source, target)) - return Refused(target, "可驗證路徑為 SQLite STRICT ↔ PostgreSQL,以及 MySQL 8.4/MariaDB 11.4 ↔ PostgreSQL 受控子集;其他配對在開連線前拒絕,不當成原生 restore。請見方向矩陣。"); + return Refused(target, "可驗證路徑為 SQLite STRICT ↔ PostgreSQL、MySQL 8.0/8.4 與 MariaDB 10.11/11.4 ↔ PostgreSQL,以及各自同引擎新舊版本受控搬移;其他配對拒絕,不當成原生 restore。請見方向矩陣。"); using var sourceOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(source); using var targetOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(target); await using var origin = await sourceOwner.OpenMigrationConnectionAsync(source.Database, token, readOnly: true).ConfigureAwait(false); diff --git a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs index 97c4026..5965958 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs @@ -8,6 +8,14 @@ internal static class LogicalMigrationTests { public static async Task RunAsync() { + foreach (var version in new[] { "8.0.46", "8.4.11", "10.11.19-MariaDB-ubu2204", "11.4.13-MariaDB-ubu2404" }) + Check(LogicalMigrationService.SupportedMySqlVersion(version), "Explicit supported MySQL family version " + version); + foreach (var version in new[] { "8.1.0", "8.0.46-Percona", "8.4.11-other", "10.6.0-MariaDB", "11.5.0-MariaDB", "11.4.13-MariaDB-other fork", "unknown" }) + Check(!LogicalMigrationService.SupportedMySqlVersion(version), "Unknown version/fork fails closed " + version); + Check(LogicalMigrationService.SupportedMySqlIdentity("8.0.46", "MySQL Community Server - GPL") && + LogicalMigrationService.SupportedMySqlIdentity("10.11.19-MariaDB", "mariadb.org binary distribution"), "Official family identity matches"); + Check(!LogicalMigrationService.SupportedMySqlIdentity("8.0.46", "Percona Server (GPL)") && + !LogicalMigrationService.SupportedMySqlIdentity("10.11.19-MariaDB", "unknown fork"), "Bare version with unknown vendor fails closed"); Check(LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('a', 1996), 1L }), "PG conservative tuple boundary fits"); Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('a', 1997), 1L }), "PG tuple alignment boundary rejects"); Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('中', 700) }), "PG UTF8 byte budget not character count"); diff --git a/mySQLPunk.Desktop/LogicalMigrationWindow.cs b/mySQLPunk.Desktop/LogicalMigrationWindow.cs index 7dea8ad..1e990f9 100644 --- a/mySQLPunk.Desktop/LogicalMigrationWindow.cs +++ b/mySQLPunk.Desktop/LogicalMigrationWindow.cs @@ -10,7 +10,7 @@ public sealed class LogicalMigrationWindow : Window { private readonly ConnectionProfile _source; private readonly ComboBox _target = new() { Name = "MigrationTarget", HorizontalAlignment = HorizontalAlignment.Stretch }; - private readonly ComboBox _version = new() { ItemsSource = new[] { "SQLite 3(3.37–3.53)", "PostgreSQL 14", "PostgreSQL 15", "PostgreSQL 16", "PostgreSQL 17", "MySQL 8.4", "MariaDB 11.4" } }; + private readonly ComboBox _version = new() { ItemsSource = new[] { "SQLite 3(3.37–3.53)", "PostgreSQL 14", "PostgreSQL 15", "PostgreSQL 16", "PostgreSQL 17", "MySQL 8.x(僅 8.0/8.4,依實際預覽)", "MariaDB 11.4", "MariaDB 10.11" } }; private readonly TextBox _database = new() { Name = "MigrationDatabase", PlaceholderText = "另建空目標資料庫名稱/SQLite 完整檔案路徑" }; private readonly TextBox _password = new() { PasswordChar = '●', PlaceholderText = "目標密碼(只留記憶體,不讀秘密庫)" }; private readonly TextBox _sshPassword = new() { PasswordChar = '●', PlaceholderText = "目標 SSH 密碼(如需要)" }; @@ -40,7 +40,7 @@ public LogicalMigrationWindow(ConnectionProfile source, IReadOnlyList { _preview = null; _apply.IsEnabled = false; @@ -108,5 +108,5 @@ private void InvalidatePreview() _preview = null; _approvedTarget = null; _apply.IsEnabled = false; } - private int SelectedMajor() => _version.SelectedIndex switch { 0 => 3, 5 => 8, 6 => 11, _ => 13 + _version.SelectedIndex }; + private int SelectedMajor() => _version.SelectedIndex switch { 0 => 3, 5 => 8, 6 => 11, 7 => 10, _ => 13 + _version.SelectedIndex }; } diff --git a/tests/DesktopProfileOperationHarness/Program.cs b/tests/DesktopProfileOperationHarness/Program.cs index d61e1aa..6c627b1 100644 --- a/tests/DesktopProfileOperationHarness/Program.cs +++ b/tests/DesktopProfileOperationHarness/Program.cs @@ -92,9 +92,11 @@ ((ComboBox)Field(migration, "_version")!).SelectedIndex = 0; var versions = (ComboBox)Field(migration, "_version")!; versions.SelectedIndex = 5; - Check((int)Invoke(migration, "SelectedMajor")! == 8, "Actual migration UI selects MySQL 8.4 explicitly"); + Check((int)Invoke(migration, "SelectedMajor")! == 8 && versions.ItemsSource!.Cast().ElementAt(5).Contains("8.0/8.4"), "Actual migration UI labels supported MySQL 8.x minors without claiming a fixed minor"); versions.SelectedIndex = 6; Check((int)Invoke(migration, "SelectedMajor")! == 11, "Actual migration UI selects MariaDB 11.4 explicitly"); + versions.SelectedIndex = 7; + Check((int)Invoke(migration, "SelectedMajor")! == 10, "Actual migration UI selects MariaDB 10.11 explicitly"); versions.SelectedIndex = 0; ((TextBox)Field(migration, "_database")!).Text = migrationTarget; var inspectMigration = (Button)Field(migration, "_inspect")!; diff --git a/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs b/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs index 5fc99bb..542072b 100644 --- a/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs +++ b/tests/MySqlLogicalMigrationHarness/MySqlMigrationRegressions.cs @@ -56,6 +56,75 @@ private static async Task MigrateAsync(ConnectionProfile source, ConnectionProfi private async Task ObjectsAsync(string database) => Convert.ToInt64(await SqlAsync(true, database, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()")); + public async Task RunVersionsAsync(int newerPort, int otherFamilyPort) + { + var newer = new MySqlMigrationRegressions(pgPort, newerPort); + var oldEngine = await EngineAsync(); var newEngine = await newer.EngineAsync(); + if (!LogicalMigrationService.SupportedMySqlVersion(oldEngine.Version) || !LogicalMigrationService.SupportedMySqlVersion(newEngine.Version) || + new[] { "8.1.0", "8.0.46-Percona", "8.4.11-other", "10.6.0-MariaDB", "11.5.0-MariaDB", "11.4.13-MariaDB-other fork" }.Any(LogicalMigrationService.SupportedMySqlVersion)) + throw new Exception("Unknown version/fork accepted."); + var source = "version_seed_" + myPort; + await CreateSeedAsync(source); + await CreateMyAsync("original", oldEngine.Collation); + await MigrateAsync(Pg(source), My("original"), oldEngine.Major); + await newer.CreateMyAsync("upgrade", newEngine.Collation); + var approved = await LogicalMigrationService.PreviewAsync(My("original"), newer.My("upgrade"), newEngine.Major); + if (!approved.CanStart || approved.SourceVersion != oldEngine.Version || approved.TargetVersion != newEngine.Version || + approved.Tables.Single(table => table.Name == "records").PrimaryKeyName != "PRIMARY") throw new Exception("Actual versions/PRIMARY lost in preview."); + var result = await LogicalMigrationService.RunAsync(My("original"), newer.My("upgrade"), newEngine.Major, approved, "upgrade"); + if (result.Outcome != LogicalMigrationOutcome.Verified) throw new Exception(result.Report); + await CreateMyAsync("downgrade", oldEngine.Collation); + await MigrateAsync(newer.My("upgrade"), My("downgrade"), oldEngine.Major); + await SqlAsync(false, "postgres", "CREATE DATABASE version_back_" + myPort); + await MigrateAsync(My("downgrade"), Pg("version_back_" + myPort), 16); + await using (var connection = Connection(true, "downgrade")) + { + await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "INSERT INTO records(id,number) VALUES(1,1)"; + try { await command.ExecuteNonQueryAsync(); throw new Exception("Duplicate PRIMARY accepted."); } + catch (MySqlException exception) when (exception.Number == 1062) { } + } + if (Convert.ToInt64(await SqlAsync(true, "downgrade", "SELECT COUNT(*) FROM records")) != 1007 || + Convert.ToInt64(await SqlAsync(true, "downgrade", "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='records' AND INDEX_NAME='records_optional'")) != 1) + throw new Exception("Rows or named nullable bigint index lost."); + Console.WriteLine("PASS actual " + oldEngine.Version + " -> " + newEngine.Version + " -> " + oldEngine.Version + + "; 1007 rows + empty table; composite PRIMARY, named nullable bigint index, Int64 extrema, UTF8/emoji/NULL/empty; production schema/SHA256 and PG seed/roundtrip Verified"); + + await CreateMyAsync("fault", oldEngine.Collation); + approved = await LogicalMigrationService.PreviewAsync(newer.My("upgrade"), My("fault"), oldEngine.Major); + var fired = false; + result = await LogicalMigrationService.RunWithMySqlObserverAsync(newer.My("upgrade"), My("fault"), oldEngine.Major, approved, CancellationToken.None, + (stage, _, _) => { if (stage == "batch") { fired = true; throw new IOException("Synthetic version batch fault."); } }); + if (!fired || result.Outcome != LogicalMigrationOutcome.StagingRemains || !result.RetryBlocked || !result.Report.Contains("_mp_", StringComparison.Ordinal)) + throw new Exception("Version fault failed closed incorrectly."); + if ((await LogicalMigrationService.PreviewAsync(newer.My("upgrade"), My("fault"), oldEngine.Major)).CanStart) + throw new Exception("Residue allowed blind restart."); + + await newer.CreateMyAsync("stale", newEngine.Collation); + approved = await LogicalMigrationService.PreviewAsync(My("original"), newer.My("stale"), newEngine.Major); + await SqlAsync(true, "original", "UPDATE records SET note='changed' WHERE id=1"); + result = await LogicalMigrationService.RunAsync(My("original"), newer.My("stale"), newEngine.Major, approved, "stale"); + if (result.Outcome != LogicalMigrationOutcome.Refused || await newer.ObjectsAsync("stale") != 0) throw new Exception("Same-count stale source wrote DDL."); + var wrongVersion = await LogicalMigrationService.PreviewAsync(My("original"), newer.My("stale"), newEngine.Major + 1); + if (wrongVersion.CanStart || !wrongVersion.Blockers.Any(blocker => blocker.Contains("版本與實際", StringComparison.Ordinal))) throw new Exception("Wrong selected major accepted."); + if ((await LogicalMigrationService.PreviewAsync(My("original"), My("original"), oldEngine.Major)).CanStart) + throw new Exception("Same database accepted."); + var other = new MySqlMigrationRegressions(pgPort, otherFamilyPort); + var otherEngine = await other.EngineAsync(); + var forkTarget = "fork_" + myPort; + await other.CreateMyAsync(forkTarget, otherEngine.Collation); + var forkPreview = await LogicalMigrationService.PreviewAsync(My("original"), other.My(forkTarget), otherEngine.Major); + if (forkPreview.CanStart || !forkPreview.Blockers.Any(blocker => blocker.Contains("MySQL ↔ MariaDB", StringComparison.Ordinal)) || await other.ObjectsAsync(forkTarget) != 0) + throw new Exception("Cross-fork direction accepted."); + + await SqlAsync(true, "original", "ALTER TABLE empty_table ADD INDEX records_optional(id)"); + await newer.CreateMyAsync("table_scoped_index", newEngine.Collation); + await MigrateAsync(My("original"), newer.My("table_scoped_index"), newEngine.Major); + await SqlAsync(false, "postgres", "CREATE DATABASE collision_back_" + myPort); + if ((await LogicalMigrationService.PreviewAsync(My("original"), Pg("collision_back_" + myPort), 16)).CanStart) + throw new Exception("PostgreSQL index namespace collision accepted."); + Console.WriteLine("PASS same-engine staging fault/retry blocked, stale source pre-DDL refusal, selected version mismatch/self and cross-fork refusal; table-scoped duplicate index names preserved, PG collision refused"); + } + public async Task RunBoundariesAsync(long packetBytes, bool crash) { var engine = await EngineAsync(); diff --git a/tests/MySqlLogicalMigrationHarness/Program.cs b/tests/MySqlLogicalMigrationHarness/Program.cs index 5d2e080..1df2af5 100644 --- a/tests/MySqlLogicalMigrationHarness/Program.cs +++ b/tests/MySqlLogicalMigrationHarness/Program.cs @@ -25,12 +25,21 @@ { await connection.OpenAsync(); Console.WriteLine("Actual PostgreSQL=" + connection.PostgreSqlVersion); } -foreach (var port in myPorts) +var probePorts = args.Length > 5 && args[3] is "boundaries" or "versions" + ? myPorts.Concat(new[] { int.Parse(args[4]), int.Parse(args[5]) }) : myPorts; +foreach (var port in probePorts) { await using var connection = new MySqlConnection(MyConnection(port)); await connection.OpenAsync(); - Console.WriteLine("Actual MySQL family=" + connection.ServerVersion); + await using var command = connection.CreateCommand(); command.CommandText = "SELECT CONCAT(VERSION(),' / ',@@version_comment)"; + Console.WriteLine("Actual MySQL family=" + await command.ExecuteScalarAsync()); } if (args.Contains("probe")) return; +if (args.Length > 3 && args[3] == "versions") +{ + await new MySqlMigrationRegressions(pgPort, int.Parse(args[4])).RunVersionsAsync(myPorts[0], myPorts[1]); + await new MySqlMigrationRegressions(pgPort, int.Parse(args[5])).RunVersionsAsync(myPorts[1], myPorts[0]); + return; +} if (args.Length > 3 && args[3] == "boundaries") { foreach (var port in myPorts) await new MySqlMigrationRegressions(pgPort, port).RunBoundariesAsync(8388608, true); diff --git a/tests/Run-MySqlLogicalMigration.py b/tests/Run-MySqlLogicalMigration.py index 149ea17..736dca6 100644 --- a/tests/Run-MySqlLogicalMigration.py +++ b/tests/Run-MySqlLogicalMigration.py @@ -12,7 +12,7 @@ output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/tmp') / ('mysqlpunk-my-pg-' + uuid.uuid4().hex) output.mkdir(parents=True, exist_ok=False) mode = sys.argv[2] if len(sys.argv) > 2 else 'regular' -if mode not in ('regular', 'boundaries'): +if mode not in ('regular', 'boundaries', 'versions'): raise ValueError('Unknown fixture mode') project = root / 'tests/MySqlLogicalMigrationHarness/Review.csproj' subprocess.run(['dotnet', 'build', str(project), '-c', 'Release'], check=True) @@ -42,16 +42,19 @@ def docker(*arguments): if mode == 'boundaries': engines += [('mysql:8.4', 3306, '/var/lib/mysql', 'MYSQL_ROOT_PASSWORD'), ('mariadb:11.4', 3306, '/var/lib/mysql', 'MARIADB_ROOT_PASSWORD')] + if mode == 'versions': + engines += [('mysql:8.0', 3306, '/var/lib/mysql', 'MYSQL_ROOT_PASSWORD'), + ('mariadb:10.11', 3306, '/var/lib/mysql', 'MARIADB_ROOT_PASSWORD')] for index, (tag, port, directory, variable) in enumerate(engines): image = json.loads(docker('image', 'inspect', tag, '--format', '{{json .}}')) - options = [] if port == 5432 else ['--max-allowed-packet=' + ('65536' if index > 2 else '8388608')] + options = [] if port == 5432 or mode == 'regular' else ['--max-allowed-packet=' + ('65536' if mode == 'boundaries' and index > 2 else '8388608')] container = docker('run', '-d', '--name', key + '-' + str(index), '--label', 'mysqlpunk.test.owner=' + key, '--cpus=1', '--memory=1g', '--pids-limit=512', '--mount', 'type=tmpfs,destination=' + directory + ',tmpfs-size=536870912', '-e', variable, '-p', '127.0.0.1::' + str(port), image['Id'], *options) containers.append(container) ports.append(docker('port', container, str(port) + '/tcp').split(':')[-1]) metadata['images'].append({'tag': tag, 'id': image['Id'], 'digests': image.get('RepoDigests', [])}) - arguments = ports[:3] + (['boundaries', *ports[3:]] if mode == 'boundaries' else []) + arguments = ports[:3] + ([mode, *ports[3:]] if mode != 'regular' else []) command = ['dotnet', str(project.parent / 'bin/Release/net8.0/MySqlPunk.MySqlLogicalMigrationHarness.dll'), *arguments] for attempt in range(60): probe = subprocess.run(command + ['probe'], env=environment, text=True, capture_output=True, timeout=20)