DB migrations: bundled SQL applied automatically after install

Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL
scripts. The launcher applies them right after extraction, in the same
install session as the binary copy, so the user never lands on a PROSERVE
build whose schema doesn't match its code.

Service (PSLauncher.Core/Migrations)
- IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector,
  BSD-licensed). Each .sql runs in a transaction; on failure the DB
  state is rolled back and the install completes (files only) but the
  user is warned to fix the connection / replay later.
- Tracking table _launcher_migrations (filename, applied_at, checksum,
  duration_ms) — same model as Flyway / Doctrine. Already-applied scripts
  are skipped on subsequent installs. Modified scripts trigger a warning
  log without blocking.
- Custom SQL splitter that respects strings/comments/backticks so a single
  .sql file can contain multiple statements separated by `;`.
- DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password
  in config.json (same protection as the license key).

Config (PSLauncher.Models/LocalConfig.cs)
- New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty
  password, Database=proserve, AutoApplyMigrations=true. Defaults match a
  fresh XAMPP install. Override via Settings (next commit).

Install pipeline (MainViewModel)
- After ZipInstaller.InstallAsync and before declaring the install complete,
  if AutoApplyMigrations and a _migrations/ folder exists, run
  ApplyMigrationsAsync with progress reporting (per-file %, filename in
  footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must
  be running and pointing to Settings → Database for connection params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:21:53 +02:00
parent 48d601176d
commit 6f8a320d69
8 changed files with 526 additions and 0 deletions

View File

@@ -401,6 +401,30 @@ public static class Strings
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
);
// ==================== DB MIGRATIONS ====================
public static string StatusApplyingMigrations(string version) => T(
$"Application des migrations base de données v{version}…",
$"Applying database migrations for v{version}…",
$"正在应用 v{version} 的数据库迁移…",
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
);
public static string ProgressMigration(int done, int total, string filename) => T(
$"🗄 Migration {done}/{total} : {filename}",
$"🗄 Migration {done}/{total}: {filename}",
$"🗄 迁移 {done}/{total}{filename}",
$"🗄 การโยกย้าย {done}/{total}: {filename}",
$"🗄 الترقية {done}/{total}: {filename}"
);
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
public static string MsgMigrationFailed(string detail) => T(
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
$"数据库迁移失败:\n\n{detail}\n\n版本已解压但数据库未被修改自动回滚。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
);
public static string StatusPreparingDownload(string version) => T(
$"Préparation du téléchargement v{version}…",
$"Preparing download for v{version}…",

View File

@@ -0,0 +1,313 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using MySqlConnector;
using PSLauncher.Models;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Implémentation MySQL/MariaDB du service de migration.
/// Workflow :
/// <code>
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
/// 2. SELECT déjà-appliquées
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
/// 4. Pour chaque non-appliquée :
/// START TRANSACTION
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
/// INSERT INTO _launcher_migrations
/// COMMIT
/// Si exception → ROLLBACK + abort + reporte l'erreur
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
/// </code>
/// </summary>
public sealed class DatabaseMigrationService : IDatabaseMigrationService
{
private const string TrackingTable = "_launcher_migrations";
private readonly Func<DatabaseConfig> _configProvider;
private readonly Func<string?> _passwordProvider;
private readonly ILogger<DatabaseMigrationService> _logger;
public DatabaseMigrationService(
Func<DatabaseConfig> configProvider,
Func<string?> passwordProvider,
ILogger<DatabaseMigrationService> logger)
{
_configProvider = configProvider;
_passwordProvider = passwordProvider;
_logger = logger;
}
public async Task<string?> TestConnectionAsync(CancellationToken ct)
{
try
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new MySqlCommand("SELECT 1", conn);
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct)
{
var migrationsDir = Path.Combine(installFolder, "_migrations");
if (!Directory.Exists(migrationsDir))
{
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
// Liste les .sql, ignore les autres extensions (README, etc.)
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
.ToList();
if (files.Count == 0)
{
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
var outcomes = new List<MigrationOutcome>();
for (int i = 0; i < files.Count; i++)
{
ct.ThrowIfCancellationRequested();
var filepath = files[i];
var filename = Path.GetFileName(filepath);
progress?.Report(new MigrationProgress(i, files.Count, filename));
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
var checksum = ComputeSha256(sql);
if (applied.TryGetValue(filename, out var prevChecksum))
{
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
{
// Détection d'une modif après-publication : on skip mais on log fort.
_logger.LogWarning(
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
"Ignored to preserve historical truth — write a NEW migration file instead.",
filename, prevChecksum[..12], checksum[..12]);
}
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
continue;
}
var sw = Stopwatch.StartNew();
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
await using var insertCmd = new MySqlCommand(
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
conn, tx);
insertCmd.Parameters.AddWithValue("@f", filename);
insertCmd.Parameters.AddWithValue("@c", checksum);
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
sw.Stop();
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
}
}
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
}
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var list = new List<AppliedMigration>();
await using var cmd = new MySqlCommand(
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(new AppliedMigration(
reader.GetString(0),
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
reader.GetString(2),
reader.GetInt64(3)));
}
return list;
}
// ----- helpers -----
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
{
var cfg = _configProvider();
var pwd = _passwordProvider() ?? string.Empty;
var csb = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
UserID = cfg.User,
Password = pwd,
Database = cfg.Database,
// Désactive le pooling : on ouvre une connexion par opération courte,
// pas besoin de garder un pool ouvert sur la machine du user.
Pooling = false,
ConnectionTimeout = 10,
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
AllowUserVariables = true,
};
var conn = new MySqlConnection(csb.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return conn;
}
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{
const string ddl = $"""
CREATE TABLE IF NOT EXISTS {TrackingTable} (
id INT PRIMARY KEY AUTO_INCREMENT,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
checksum CHAR(64) NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
await using var cmd = new MySqlCommand(ddl, conn);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
map[reader.GetString(0)] = reader.GetString(1);
return map;
}
/// <summary>
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
/// supporter <see cref="CancellationToken"/> proprement.
/// </summary>
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
{
foreach (var stmt in SplitStatements(sql))
{
ct.ThrowIfCancellationRequested();
var trimmed = stmt.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
// Skip si juste un commentaire ou une ligne blanche
if (IsAllCommentsOrWhitespace(trimmed)) continue;
await using var cmd = new MySqlCommand(trimmed, conn, tx);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
private static bool IsAllCommentsOrWhitespace(string s)
{
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
return string.IsNullOrWhiteSpace(stripped);
}
/// <summary>
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
/// </summary>
private static IEnumerable<string> SplitStatements(string sql)
{
var sb = new StringBuilder();
int i = 0;
char? quote = null;
bool inLineComment = false;
bool inBlockComment = false;
while (i < sql.Length)
{
char c = sql[i];
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
if (inLineComment)
{
sb.Append(c);
if (c == '\n') inLineComment = false;
i++;
continue;
}
if (inBlockComment)
{
sb.Append(c);
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
i++;
continue;
}
if (quote is not null)
{
sb.Append(c);
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
if (c == quote) quote = null;
i++;
continue;
}
// hors-quote, hors-commentaire
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
if (c == ';')
{
yield return sb.ToString();
sb.Clear();
i++;
continue;
}
sb.Append(c);
i++;
}
if (sb.Length > 0)
{
var tail = sb.ToString().Trim();
if (tail.Length > 0) yield return tail;
}
}
private static string ComputeSha256(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,36 @@
using System.Security.Cryptography;
using System.Text;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
/// </summary>
public static class DatabasePasswordProtector
{
public static string? Protect(string? clearPassword)
{
if (string.IsNullOrEmpty(clearPassword)) return null;
var data = Encoding.UTF8.GetBytes(clearPassword);
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public static string? Unprotect(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var protectedBytes = Convert.FromBase64String(base64);
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(data);
}
catch
{
// Cache déplacé sur autre machine / autre user → non déchiffrable
return null;
}
}
}

View File

@@ -0,0 +1,57 @@
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
/// </summary>
public interface IDatabaseMigrationService
{
/// <summary>
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
/// </summary>
Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
/// </summary>
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
/// <summary>
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
/// </summary>
Task<string?> TestConnectionAsync(CancellationToken ct);
}
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
public sealed record MigrationResult(
IReadOnlyList<MigrationOutcome> Outcomes,
bool AllSucceeded,
string? FailureMessage)
{
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
}
public sealed record MigrationOutcome(
string Filename,
MigrationStatus Status,
long DurationMs,
string? Error);
public enum MigrationStatus { Skipped, Applied, Failed }
public sealed record AppliedMigration(
string Filename,
DateTime AppliedAt,
string Checksum,
long DurationMs);

View File

@@ -12,6 +12,8 @@
<PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
<PackageReference Include="MySqlConnector" Version="2.3.7" />
</ItemGroup>
<ItemGroup>