Browse Source

fix(mdp): 恢复1.0.517失败迁移的安全重试

一、故障

1.0.517 末尾的
    DELETE FROM dwd_requirement_examine_detail WHERE material_role IS NULL
谓词正确但跑不完:material_role 是同一脚本刚 ADD 的列、无索引,
实测共享库 aidopdev 该表 975,057 行 / 379MB 数据 + 293MB 索引,
命中 1,028,210 行 ≈ 100%,等于全表扫 + 百万行 undo log。
实测 status=Failed / elapsed_ms=645038 /
「第 24/26 条语句失败:EXECUTE stmt | The Command Timeout expired」,
超过 MigrationCommandTimeoutSeconds=600s。
Failed 脚本每次启动都会重跑、StopApplicationOnFailure 默认 true,
于是「启动→重试→超时→停机」死循环,阻塞所有需要重启后端的实例。

我当初写这段时论证了「为什么必须删、为什么不能回填、谓词为什么这么选」,
却没有估算它要跑多久 —— 1019060 这个数字就写在同一段注释里,
被我当作「数据不可信」的论据引用,却没有同时当作执行成本来看。

二、为什么不直接改 1.0.517.sql

ShouldSkipScript 的语义是不对称的:
  · 日志 status=Success ⇒ 校验文件 SHA256,不符直接抛「禁止静默覆盖」并中断启动;
  · 日志为其它状态(含 Failed)⇒ 不校验 hash,用新内容重跑。
所以改脚本虽能救 Failed 环境,却会让所有已 Success 的环境启动即崩 ——
本地沙箱实测复现过一次。那是把一个阻塞换成另一个阻塞,不是修复。
同样不接受:手改迁移日志、调大超时、关 StopApplicationOnFailure ——
前者伪造执行历史,后两者只是让问题晚一点或安静一点发生。

三、修法:重跑前恢复,脚本与 hash 一个字节不动

新增 MigrationRecoveryPolicy(纯函数、不碰库)+ AutoVersionUpdate 里
一处 TryRecoverKnownFailedMigration 调用(执行副作用)。
仅当四道闸门全部通过,才在重跑前 TRUNCATE 目标表:
  ① 版本在登记表里精确登记(只按版本号,不做模式匹配);
  ② 迁移日志已有该版本且状态为 Failed
     —— 从未跑过=正常首次执行;已 Success 的根本进不了 pending;
  ③ 日志记录的 hash == 已发布原始脚本 hash;
  ④ 磁盘脚本内容也仍是该原始内容。
③④ 任一不符 ⇒ 显式抛错停机,不做破坏性操作(TRUNCATE 不可回滚,
不能拿一条针对未知脚本的清表去赌)。
恢复不写迁移日志:状态一律由原有正常流程写入,
让「这条迁移到底成没成功」始终是脚本自己跑出来的结论。

TRUNCATE 安全的三个前提均已实测:迁移执行器按 ';' 拆句、逐条 ExecuteNonQuery、
全链路无 BeginTran(本就没有脚本级原子性,隐式提交不破坏任何东西);
该表外键引用数=0、触发器数=0;100% 派生、读者数=0,下一轮 S1 跑批完整重建。
幂等:TRUNCATE 空表安全,恢复后崩溃则下次以相同输入再清一次空表。

四、实测(本地沙箱,共享库全程只读)

CASE A  Failed + 原始 hash
  日志:Migration recovery start/done version=1.0.517
        strategy=DWD_TRUNCATE_PRE_RETRY table=dwd_requirement_examine_detail
  表 794 → 0,原脚本原样重跑 ⇒ Success/Success,elapsed 43ms,26 条语句,
  file_hash 仍为 EF27229…(原始值未变),后端启动并持续存活。
  随后一轮正常 S1 同步完整重建:797=753 行/753 current/729 投入料/24 根/0 空角色,
  838=41/41/27/14/0。

CASE B  Success + 原始 hash
  恢复完全不触发(日志 0 条),迁移行保持 Success/Success,后端正常启动。

CASE C  Failed + 未知 hash
  显式拒绝并停机:「命中已登记的恢复策略 DWD_TRUNCATE_PRE_RETRY,但拒绝执行:
  迁移日志记录的 SHA256 与已登记的原始脚本不一致……不对未知版本执行」,
  TRUNCATE 未执行,迁移行保持 Failed,无任何破坏。

五、注意

登记表是破坏性授权清单,新增一条等于授权一次不可回滚操作,必须逐条评审;
测试里有一条断言登记的 hash 必须等于仓库中 1.0.517.sql 的真实 SHA256,
防止有人改了已发布脚本却忘了同步登记表、导致恢复通道静默失效。

server 1.0.517 → 1.0.518(本批无新增 migration,1.0.517.sql 一字未改)
YY968XX 1 day ago
parent
commit
c77c7fcc0d

+ 63 - 0
server/Admin.NET.Core/Update/AutoVersionUpdate.cs

@@ -112,12 +112,75 @@ public static class AutoVersionUpdate
 
         foreach (var script in pending)
         {
+            TryRecoverKnownFailedMigration(db, script, migrationRows);
             ExecuteOneMigrationScript(db, script, databaseName);
         }
 
         SetEntryAssemblyCurrentVersion(currentVersionText, true);
     }
 
+    /// <summary>
+    /// 已知失败迁移的「重跑前恢复」。裁决全部交给 <see cref="MigrationRecoveryPolicy"/>(纯函数、可单测),
+    /// 本方法只负责执行副作用。
+    ///
+    /// <para><b>已 Success 的环境零影响</b>:<see cref="ShouldSkipScript"/> 已把它们排除在 pending 之外,
+    /// 本方法根本不会被调用;策略里再判一次状态是纵深防御。</para>
+    ///
+    /// <para><b>幂等</b>:TRUNCATE 空表安全。若 TRUNCATE 之后进程崩溃,下次启动日志行仍是 Failed、
+    /// hash 仍是原始值,会再 TRUNCATE 一次空表,无副作用。</para>
+    ///
+    /// <para><b>不写迁移日志</b>:恢复只清数据,状态一律由原有正常流程写入 ——
+    /// 让「这条迁移到底成没成功」始终是脚本自己跑出来的结论。</para>
+    /// </summary>
+    private static void TryRecoverKnownFailedMigration(
+        ISqlSugarClient db,
+        MigrationScript script,
+        List<MigrationLogRow> migrationRows)
+    {
+        var row = migrationRows.FirstOrDefault(x =>
+            string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
+
+        var registered = MigrationRecoveryPolicy.KnownFailedRecoveries.FirstOrDefault(x =>
+            string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
+
+        // 只有登记过的版本才需要读盘算 hash,避免给每条迁移平白加一次 IO。
+        var diskMatches = registered != null
+                          && MigrationScriptHash.MatchesFile(registered.OriginalFileHash, script.FilePath);
+
+        var outcome = MigrationRecoveryPolicy.Decide(
+            script.Version, row?.Status, row?.FileHash, diskMatches);
+
+        if (outcome.Decision == MigrationRecoveryDecision.None) return;
+
+        if (outcome.Decision == MigrationRecoveryDecision.Reject)
+            throw new InvalidOperationException(
+                $"版本 {script.Version} 处于 Failed 且命中已登记的恢复策略 {outcome.Plan!.Strategy},"
+                + $"但拒绝执行:{outcome.RejectReason}。"
+                + "该策略含不可回滚操作,不对未知版本执行,请人工确认。");
+
+        var plan = outcome.Plan!;
+        if (!TableExists(db, plan.TableName)) return;
+
+        LogHelper.Handle(
+            $"Migration recovery start version={plan.Version} strategy={plan.Strategy} table={plan.TableName}");
+
+        db.Ado.ExecuteCommand($"TRUNCATE TABLE `{plan.TableName}`");
+
+        LogHelper.Handle(
+            $"Migration recovery done version={plan.Version} strategy={plan.Strategy};"
+            + "接下来由原迁移脚本原样重跑,日志状态由正常流程写入。");
+    }
+
+    /// <summary>目标表是否存在于当前库。恢复策略只在表确实存在时才动手。</summary>
+    private static bool TableExists(ISqlSugarClient db, string tableName)
+    {
+        var count = db.Ado.SqlQuerySingle<int>(
+            "SELECT COUNT(*) FROM information_schema.TABLES "
+            + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @name",
+            new { name = tableName });
+        return count > 0;
+    }
+
     private static bool ShouldSkipScript(
         MigrationScript script,
         List<MigrationLogRow> migrationRows,

+ 107 - 0
server/Admin.NET.Core/Update/MigrationRecoveryPolicy.cs

@@ -0,0 +1,107 @@
+namespace Admin.NET.Core.Update;
+
+/// <summary>已知失败迁移的恢复裁决。</summary>
+public enum MigrationRecoveryDecision
+{
+    /// <summary>不做任何事(绝大多数情况)。</summary>
+    None = 0,
+
+    /// <summary>重跑前清空目标表,然后让原脚本原样重跑。</summary>
+    Truncate = 1,
+
+    /// <summary>拒绝并停机:命中了登记版本,但 hash 对不上,不能对未知脚本执行破坏性操作。</summary>
+    Reject = 2
+}
+
+/// <summary>一条已发布且已知会失败的迁移,及其恢复策略。</summary>
+public sealed record MigrationRecoveryPlan(
+    string Version,
+    string OriginalFileHash,
+    string Strategy,
+    string TableName);
+
+/// <summary>裁决结果。</summary>
+public sealed record MigrationRecoveryOutcome(
+    MigrationRecoveryDecision Decision,
+    MigrationRecoveryPlan? Plan,
+    string? RejectReason);
+
+/// <summary>
+/// 已知失败迁移的「重跑前恢复」策略。<b>纯函数,不碰数据库</b>,effect 由调用方执行。
+///
+/// <para><b>这不是迁移的正常语义,而是针对特定版本的一次性抢救。</b>
+/// 适用前提是:脚本已经 push、hash 已被多个环境记录,此时
+///   · 不能改脚本 —— 已 Success 的环境会因 hash 不符而拒绝启动;
+///   · 不能手改迁移日志 —— 那是伪造执行历史。
+/// 唯一安全的口子是在<b>重跑之前</b>把「让它跑不完的那点状态」清掉,
+/// 再让原脚本原样重跑成功,hash 与执行历史都保持真实。</para>
+///
+/// <para><b>1.0.517 的具体情况</b>:脚本末尾的
+/// <c>DELETE FROM dwd_requirement_examine_detail WHERE material_role IS NULL</c>
+/// 谓词正确但跑不完 —— <c>material_role</c> 是同一脚本刚 ADD 的列、无索引,
+/// 实测共享库命中 1,028,210 行 ≈ 100%,单条语句 elapsed 645,038ms,
+/// 超过迁移执行器 600s 的 CommandTimeout ⇒ Failed;而 Failed 脚本每次启动都会重跑、
+/// StopApplicationOnFailure 默认 true,于是形成「启动→重试→超时→停机」的死循环。
+/// 该表是 100% 派生、读者数为 0 的 DWD,下一轮 S1 跑批即完整重建,
+/// 因此重跑前 TRUNCATE 是安全的:原 DELETE 随后命中 0 行、瞬间完成。</para>
+/// </summary>
+public static class MigrationRecoveryPolicy
+{
+    /// <summary>
+    /// 恢复登记表。<b>只按版本号精确登记,不做任何模式匹配、不按脚本内容推断。</b>
+    /// 新增条目等于授权一次破坏性操作,必须逐条评审。
+    /// </summary>
+    public static readonly IReadOnlyList<MigrationRecoveryPlan> KnownFailedRecoveries = new[]
+    {
+        new MigrationRecoveryPlan(
+            Version: "1.0.517",
+            OriginalFileHash: "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B",
+            Strategy: "DWD_TRUNCATE_PRE_RETRY",
+            TableName: "dwd_requirement_examine_detail")
+    };
+
+    /// <summary>
+    /// 裁决是否要在重跑前做恢复。四道闸门,全部通过才 <see cref="MigrationRecoveryDecision.Truncate"/>。
+    /// </summary>
+    /// <param name="version">待执行脚本的版本号。</param>
+    /// <param name="loggedStatus">迁移日志里该版本的状态;从未跑过时传 null。</param>
+    /// <param name="loggedFileHash">迁移日志里记录的脚本 SHA256。</param>
+    /// <param name="diskHashMatchesOriginal">磁盘上的脚本内容是否仍等于登记的原始 hash。</param>
+    public static MigrationRecoveryOutcome Decide(
+        string version,
+        string? loggedStatus,
+        string? loggedFileHash,
+        bool diskHashMatchesOriginal)
+    {
+        // 闸门 1:版本必须精确登记。
+        var plan = KnownFailedRecoveries.FirstOrDefault(x =>
+            string.Equals(x.Version, version, StringComparison.OrdinalIgnoreCase));
+        if (plan == null)
+            return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, null, null);
+
+        // 闸门 2:必须已经跑过且失败。
+        // 从未跑过(无日志行)= 正常首次执行,不是抢救场景;
+        // 已 Success 的根本走不到这里(ShouldSkipScript 已把它排除出 pending),
+        // 这里再判一次是纵深防御,确保本策略永远不会碰成功过的环境。
+        if (string.IsNullOrWhiteSpace(loggedStatus))
+            return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null);
+        if (!string.Equals(loggedStatus, "Failed", StringComparison.OrdinalIgnoreCase))
+            return new MigrationRecoveryOutcome(MigrationRecoveryDecision.None, plan, null);
+
+        // 闸门 3:日志记录的 hash 必须等于已发布的原始脚本 hash。
+        // 不符 = 「这个 1.0.517 不是我们登记的那一个」,
+        // 此时继续等于拿不可回滚的 TRUNCATE 去赌未知脚本 ⇒ 显式拒绝。
+        if (!string.Equals(loggedFileHash?.Trim(), plan.OriginalFileHash, StringComparison.OrdinalIgnoreCase))
+            return new MigrationRecoveryOutcome(
+                MigrationRecoveryDecision.Reject, plan,
+                $"迁移日志记录的 SHA256 与已登记的原始脚本不一致:记录={loggedFileHash},登记={plan.OriginalFileHash}");
+
+        // 闸门 4:磁盘脚本也必须仍是原始内容(防止本地改过脚本又恰好处于 Failed)。
+        if (!diskHashMatchesOriginal)
+            return new MigrationRecoveryOutcome(
+                MigrationRecoveryDecision.Reject, plan,
+                "磁盘脚本内容与已登记的原始脚本不一致,恢复策略只针对已发布的原始脚本");
+
+        return new MigrationRecoveryOutcome(MigrationRecoveryDecision.Truncate, plan, null);
+    }
+}

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.517</AssemblyVersion>
-    <FileVersion>1.0.517</FileVersion>
-    <Version>1.0.517</Version>
+    <AssemblyVersion>1.0.518</AssemblyVersion>
+    <FileVersion>1.0.518</FileVersion>
+    <Version>1.0.518</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 192 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/Migration/MigrationRecoveryPolicyTests.cs

@@ -0,0 +1,192 @@
+using System.Security.Cryptography;
+using Admin.NET.Core.Update;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.Migration;
+
+/// <summary>
+/// 已知失败迁移「重跑前恢复」的策略契约。
+///
+/// <para>被守护的核心不变量只有一条:<b>这条破坏性通道必须极其难以被误触发。</b>
+/// 它会 TRUNCATE 一张真实的表且不可回滚,所以四道闸门里任意一道松掉都是事故:
+///   · 版本没登记 → 不能动;
+///   · 从未跑过 / 已经成功 → 不能动(尤其「已成功」,动了就是破坏别人正常的库);
+///   · 日志 hash 或磁盘 hash 与已发布的原始脚本对不上 → 必须显式拒绝,不能沉默放行。</para>
+///
+/// <para>策略是纯函数,因此这些用例不需要数据库;副作用(TRUNCATE / 抛异常)由
+/// AutoVersionUpdate 执行,其真实运行结果另由本地 runtime 验证覆盖。</para>
+/// </summary>
+public class MigrationRecoveryPolicyTests
+{
+    private const string V = "1.0.517";
+    private const string OriginalHash = "EF2722954B4BB5BEA465F63E764C927F01E9E52109738478E89CE94145ECB50B";
+
+    // ── CASE A:Failed + 原始 hash ⇒ 允许恢复 ──────────────────────────────────────
+    [Fact]
+    public void CaseA_FailedWithOriginalHash_Truncates()
+    {
+        var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: true);
+
+        Assert.Equal(MigrationRecoveryDecision.Truncate, outcome.Decision);
+        Assert.Equal("dwd_requirement_examine_detail", outcome.Plan!.TableName);
+        Assert.Equal("DWD_TRUNCATE_PRE_RETRY", outcome.Plan.Strategy);
+        Assert.Null(outcome.RejectReason);
+    }
+
+    /// <summary>大小写与首尾空白不应改变裁决 —— 不同驱动回读 hash 的形态不一致。</summary>
+    [Theory]
+    [InlineData("Failed")]
+    [InlineData("failed")]
+    [InlineData("FAILED")]
+    public void CaseA_StatusComparisonIsCaseInsensitive(string status)
+    {
+        Assert.Equal(MigrationRecoveryDecision.Truncate,
+            MigrationRecoveryPolicy.Decide(V, status, "  " + OriginalHash.ToLowerInvariant() + "  ", true).Decision);
+    }
+
+    // ── CASE B:已 Success ⇒ 绝不触碰 ─────────────────────────────────────────────
+    /// <summary>
+    /// 这是整组测试里最重要的一条。已成功的环境说明该迁移在那里本来就跑对了,
+    /// 恢复通道一旦在这种环境上生效,就是把别人正常的库清空。
+    /// </summary>
+    [Fact]
+    public void CaseB_AlreadySucceeded_IsNeverTouched()
+    {
+        var outcome = MigrationRecoveryPolicy.Decide(V, "Success", OriginalHash, diskHashMatchesOriginal: true);
+
+        Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
+        Assert.Null(outcome.RejectReason);
+    }
+
+    [Theory]
+    [InlineData("Success")]
+    [InlineData("Running")]
+    [InlineData("Skipped")]
+    public void CaseB_AnyNonFailedStatus_IsNeverTouched(string status)
+    {
+        Assert.Equal(MigrationRecoveryDecision.None,
+            MigrationRecoveryPolicy.Decide(V, status, OriginalHash, true).Decision);
+    }
+
+    // ── CASE C:Failed 但 hash 不是登记的那一个 ⇒ 显式拒绝 ────────────────────────
+    /// <summary>
+    /// 不能沉默跳过:沉默会让人以为「恢复没生效但也没坏事」,
+    /// 实际是我们对一个来历不明的 1.0.517 做了判断却没说。TRUNCATE 不可回滚,必须停机报错。
+    /// </summary>
+    [Fact]
+    public void CaseC_FailedWithUnknownLoggedHash_IsRejected_NotSilentlySkipped()
+    {
+        var outcome = MigrationRecoveryPolicy.Decide(
+            V, "Failed", "0000000000000000000000000000000000000000000000000000000000000000", true);
+
+        Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
+        Assert.Contains("SHA256", outcome.RejectReason!, StringComparison.Ordinal);
+    }
+
+    /// <summary>日志 hash 对得上,但磁盘脚本被改过 —— 同样拒绝。</summary>
+    [Fact]
+    public void CaseC_FailedButDiskScriptModified_IsRejected()
+    {
+        var outcome = MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, diskHashMatchesOriginal: false);
+
+        Assert.Equal(MigrationRecoveryDecision.Reject, outcome.Decision);
+        Assert.NotNull(outcome.RejectReason);
+    }
+
+    [Fact]
+    public void CaseC_FailedWithNullOrEmptyLoggedHash_IsRejected()
+    {
+        Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", null, true).Decision);
+        Assert.Equal(MigrationRecoveryDecision.Reject, MigrationRecoveryPolicy.Decide(V, "Failed", "", true).Decision);
+    }
+
+    // ── 未登记版本 / 从未跑过 ⇒ 什么都不做 ────────────────────────────────────────
+    [Theory]
+    [InlineData("1.0.516")]
+    [InlineData("1.0.518")]
+    [InlineData("1.0.510")]
+    public void UnregisteredVersion_IsNeverTouched_EvenWhenFailed(string version)
+    {
+        var outcome = MigrationRecoveryPolicy.Decide(version, "Failed", OriginalHash, true);
+
+        Assert.Equal(MigrationRecoveryDecision.None, outcome.Decision);
+        Assert.Null(outcome.Plan);
+    }
+
+    /// <summary>从未跑过(无日志行)= 正常首次执行,不是抢救场景。</summary>
+    [Fact]
+    public void NeverRun_IsNeverTouched()
+    {
+        Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, null, null, true).Decision);
+        Assert.Equal(MigrationRecoveryDecision.None, MigrationRecoveryPolicy.Decide(V, "  ", null, true).Decision);
+    }
+
+    // ── 幂等 ──────────────────────────────────────────────────────────────────────
+    /// <summary>
+    /// 恢复不写迁移日志,所以「TRUNCATE 完成后进程崩溃」时下次启动的输入完全相同,
+    /// 裁决必须仍然是 Truncate(对空表再清一次,无副作用),而不是变成 Reject 或 None。
+    /// </summary>
+    [Fact]
+    public void Decision_IsIdempotent_AcrossRepeatedStartups()
+    {
+        for (var i = 0; i < 3; i++)
+            Assert.Equal(MigrationRecoveryDecision.Truncate,
+                MigrationRecoveryPolicy.Decide(V, "Failed", OriginalHash, true).Decision);
+    }
+
+    // ── 登记表与真实脚本必须一致 ──────────────────────────────────────────────────
+    /// <summary>
+    /// 登记的 hash 必须等于仓库里那份 1.0.517.sql 的真实 SHA256。
+    ///
+    /// <para>这条防的是最隐蔽的一种走样:有人改了已发布的 1.0.517.sql 却忘了同步登记表。
+    /// 那时闸门 4 会对所有环境判 Reject,恢复通道静默失效,而故障现场看起来只是「又起不来」。</para>
+    /// </summary>
+    [Fact]
+    public void RegisteredHash_MatchesTheActualPublishedScript()
+    {
+        var plan = Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries, x => x.Version == V);
+
+        var path = FindScript($"{V}.sql");
+        var actual = Convert.ToHexString(SHA256.HashData(NormalizeToLf(File.ReadAllBytes(path))));
+
+        Assert.Equal(plan.OriginalFileHash, actual, ignoreCase: true);
+    }
+
+    /// <summary>登记表是破坏性授权清单:新增一条等于授权一次不可回滚操作,必须显式评审。</summary>
+    [Fact]
+    public void Registry_ContainsOnlyTheDeliberatelyReviewedEntries()
+    {
+        Assert.Single(MigrationRecoveryPolicy.KnownFailedRecoveries);
+        Assert.All(MigrationRecoveryPolicy.KnownFailedRecoveries,
+            x => Assert.Equal(64, x.OriginalFileHash.Length));
+    }
+
+    private static byte[] NormalizeToLf(byte[] content)
+    {
+        var output = new byte[content.Length];
+        var length = 0;
+        for (var i = 0; i < content.Length; i++)
+        {
+            if (content[i] == 0x0D)
+            {
+                if (i + 1 < content.Length && content[i + 1] == 0x0A) i++;
+                output[length++] = 0x0A;
+                continue;
+            }
+            output[length++] = content[i];
+        }
+        return output.AsSpan(0, length).ToArray();
+    }
+
+    private static string FindScript(string fileName)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts", fileName);
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(fileName);
+    }
+}