节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
9.79 KiB
Stardust
using System.Diagnostics;
using NewLife;
using NewLife.Agent;
using NewLife.Agent.Models;
using NewLife.Log;

namespace StarAgent;

/// <summary>升级自愈</summary>
/// <remarks>
/// 旧版本代理升级时会把正在使用的 exe/dll 改名为 *.del 备份,一旦随后拉起新进程失败
/// (例如旧版用 <see cref="System.Diagnostics.Process.ProcessName"/> 拼路径,改名后得到 StarAgent.exe.del.exe),
/// 备份就一直留在部署目录里:既占磁盘,又让每次"检查更新"都报一次 Access denied。
/// <para>新版进程启动时执行一次清理与校正,让设备只要重启一次服务就能收拾完历史残局,无需人工登录机器处理。</para>
/// </remarks>
public static class UpgradeHealer
{
    /// <summary>执行自愈</summary>
    /// <param name="service">当前服务实例,用于校正服务注册;为空时跳过注册校正</param>
    /// <param name="log">日志</param>
    /// <param name="dir">待自愈的部署目录,为空时取当前进程所在目录</param>
    /// <returns>自愈结果摘要,没有需要处理的内容时返回null</returns>
    public static String? Heal(ServiceBase? service, ILog? log, String? dir = null)
    {
        dir ??= AppDomain.CurrentDomain.BaseDirectory;
        dir = dir.TrimEnd(Path.DirectorySeparatorChar);
        if (dir.IsNullOrEmpty() || !Directory.Exists(dir)) return null;

        var msgs = new List<String>();

        // 1,清理升级备份。当前进程已经加载真实文件,.del 不再被占用(占用发生在旧进程里)
        var deleted = 0;
        var failed = 0;
        try
        {
            foreach (var file in Directory.GetFiles(dir, "*.del", SearchOption.TopDirectoryOnly))
            {
                try
                {
                    File.Delete(file);
                    deleted++;
                }
                catch
                {
                    // 仍被旧实例占用时跳过,等下一次启动再清
                    failed++;
                }
            }
        }
        catch (Exception ex)
        {
            log?.Debug("Upgrade self-heal: scan backups failed: {0}", ex.Message);
        }
        if (deleted > 0) msgs.Add(L.TLog("cleaned {0} upgrade backup(s)", deleted));
        if (failed > 0) msgs.Add(L.TLog("{0} backup(s) are still in use", failed));

        // 2,校正服务注册。半途失败的升级可能把服务入口指向 .del 备份
        try
        {
            var cfg = service?.Host?.QueryConfig(service.ServiceName);
            var path = cfg?.FilePath;
            if (!path.IsNullOrEmpty() && path.Contains(".del"))
            {
                log?.Warn("Upgrade self-heal: service points to backup file {0}; re-registering", path);

                if (service.Host.Install(BuildServiceModel(service))) msgs.Add(L.TLog("service registration repaired"));
            }
        }
        catch (Exception ex)
        {
            log?.Debug("Upgrade self-heal: repair service registration failed: {0}", ex.Message);
        }

        // 3,修复 SysVinit/systemd 仍指向旧 apphost 的启动入口
        try
        {
            var entry = HealEntry(dir, log);
            if (!entry.IsNullOrEmpty()) msgs.Add(entry);
        }
        catch (Exception ex)
        {
            log?.Debug("Upgrade self-heal: repair startup entry failed: {0}", ex.Message);
        }

        // 4,清理残留升级包,避免边缘节点小容量存储被反复占用
        try
        {
            var update = dir.CombinePath("Update");
            if (Directory.Exists(update))
            {
                var count = 0;
                foreach (var file in Directory.GetFiles(update, "*.zip"))
                {
                    if (File.GetLastWriteTime(file) > DateTime.Now.AddDays(-1)) continue;

                    try
                    {
                        File.Delete(file);
                        count++;
                    }
                    catch { }
                }
                if (count > 0) msgs.Add(L.TLog("cleaned {0} stale update package(s)", count));
            }
        }
        catch (Exception ex)
        {
            log?.Debug("Upgrade self-heal: clean update packages failed: {0}", ex.Message);
        }

        return msgs.Count > 0 ? String.Join("; ", msgs) : null;
    }

    /// <summary>修复 Linux 启动入口</summary>
    /// <param name="dir">部署目录</param>
    /// <param name="log">日志</param>
    /// <returns>修复摘要,无需修复时返回null</returns>
    /// <remarks>
    /// 平台下发的 Linux 升级包只含 <c>StarAgent.dll</c> 与 runtimeconfig,不含 apphost。
    /// 升级后 init 脚本里的 <c>./StarAgent</c> 仍是旧的自包含 apphost,加载新程序集时因缺 deps.json 直接退出,
    /// 表现为升级成功却再也起不来(ok3506-buildroot 实测)。这里把过期的 apphost 换成调用 dotnet 的 shim,
    /// 让 <c>./StarAgent -s</c> 与 <c>dotnet StarAgent.dll -s</c> 两种入口都能用。
    /// </remarks>
    public static String? HealEntry(String dir, ILog? log)
    {
        if (!Runtime.Linux) return null;

        // 只处理框架依赖部署(dotnet StarAgent.dll);自包含/AOT 部署的 apphost 本身就是入口,不能替换
        var args = Environment.GetCommandLineArgs();
        var entryName = args.Length >= 1 ? Path.GetFileName(args[0]) : null;
        if (entryName.IsNullOrEmpty() || !entryName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return null;

        var host = Process.GetCurrentProcess().MainModule?.FileName + "";
        if (host.IsNullOrEmpty() || !File.Exists(host)) return null;

        return HealEntry(dir, entryName, host, log);
    }

    /// <summary>把早于入口程序集的 apphost 替换为调用 dotnet 的 shim</summary>
    /// <param name="dir">部署目录</param>
    /// <param name="entryName">入口程序集文件名,如 StarAgent.dll</param>
    /// <param name="host">dotnet 主机完整路径</param>
    /// <param name="log">日志</param>
    /// <returns>修复摘要,无需修复时返回null</returns>
    public static String? HealEntry(String dir, String entryName, String host, ILog? log)
    {
        if (dir.IsNullOrEmpty() || entryName.IsNullOrEmpty() || host.IsNullOrEmpty()) return null;

        var entry = Path.Combine(dir, entryName);
        var app = Path.Combine(dir, Path.GetFileNameWithoutExtension(entryName));
        if (!File.Exists(entry) || !File.Exists(app)) return null;

        // apphost 比程序集新说明两者来自同一次部署,保持原样
        if (File.GetLastWriteTimeUtc(app) >= File.GetLastWriteTimeUtc(entry)) return null;

        var shim = "#!/bin/sh\n" +
            "# Generated by StarAgent upgrade self-heal.\n" +
            "# Framework-dependent upgrade only replaces " + entryName + ", the old apphost can no longer load it.\n" +
            "# Keep ./" + Path.GetFileName(app) + " working for SysVinit/systemd scripts.\n" +
            "DIR=$(cd \"$(dirname \"$0\")\" && pwd)\n" +
            "exec \"" + host + "\" \"$DIR/" + entryName + "\" \"$@\"\n";

        var tmp = app + ".new";
        File.WriteAllText(tmp, shim);
        if (!Runtime.Windows) SetExecutable(tmp, log);
        // 覆盖式改名,失败时旧文件仍在,不影响启动
#if NETFRAMEWORK
        File.Delete(app);
        File.Move(tmp, app);
#else
        File.Move(tmp, app, true);
#endif

        log?.Info("Upgrade self-heal: startup entry {0} replaced by dotnet shim {1}", app, host);

        return L.TLog("startup entry {0} repaired", Path.GetFileName(app));
    }

    /// <summary>给启动脚本加执行权限</summary>
    /// <remarks>
    /// net6 没有 File.SetUnixFileMode,而 StarAgent 仍多目标 net6.0:脚本按默认 0644 覆盖启动入口后,
    /// SysVinit/systemd 再启动就会报 Permission denied,节点彻底起不来。老框架退回调用 chmod。
    /// </remarks>
    /// <param name="file">脚本文件</param>
    /// <param name="log">日志</param>
    private static void SetExecutable(String file, ILog? log)
    {
        try
        {
#if NET7_0_OR_GREATER
            File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
#else
            var p = Process.Start(new ProcessStartInfo("/bin/chmod", "+x " + file) { UseShellExecute = false });
            p?.WaitForExit(5_000);
#endif
        }
        catch (Exception ex)
        {
            log?.Debug("Upgrade self-heal: set executable mode failed: {0}", ex.Message);
        }
    }

    /// <summary>按当前进程的实际文件构造服务定义</summary>
    /// <param name="service">当前服务实例</param>
    /// <returns>服务定义</returns>
    /// <remarks>框架依赖部署(dotnet App.dll)下进程路径是 dotnet 主机,需要拼上入口程序集</remarks>
    public static ServiceModel BuildServiceModel(ServiceBase service)
    {
        var exe = Process.GetCurrentProcess().MainModule?.FileName + "";
        var args = Environment.GetCommandLineArgs();
        if (args.Length >= 1 && !exe.IsNullOrEmpty())
        {
            var fileName = Path.GetFileName(exe);
            var dll = args[0].GetFullPath();
            if (fileName.EqualIgnoreCase("dotnet", "dotnet.exe"))
                exe += " " + dll;
            else if (fileName.EqualIgnoreCase("mono", "mono.exe", "mono-sgen"))
                exe = dll;

            if (exe.Contains(' ')) exe = $"\"{exe}\"";
        }

        return new ServiceModel
        {
            ServiceName = service.ServiceName,
            DisplayName = service.DisplayName,
            Description = service.Description,
            FileName = exe,
            Arguments = "-s",
        };
    }
}