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

namespace StarAgent;

/// <summary>升级预检</summary>
/// <remarks>
/// 判断覆盖到位的新版本能否在本机跑起来。框架依赖发布的程序一旦缺少 runtimeconfig.json 要求的 .NET 运行时,
/// .NET 主机会在托管代码执行之前直接退出(Linux 实测退出码 150),冒烟测试与“重启服务”都不可能成功。
/// 这类失败必须在把控制权交出去之前识别:重启服务会结束仍在正常工作的旧进程,而新版本又起不来,节点将彻底失联。
/// <para>运行时版本按 .NET 主机的真实前滚规则判定:Disable 要求精确版本,Major/LatestMajor 允许跨主版本,
/// 其余策略只允许同一主版本内向前且补丁号不得低于要求;环境变量 DOTNET_ROLL_FORWARD / DOTNET_ROLL_FORWARD_TO_PRERELEASE 优先于 runtimeconfig。</para>
/// <para>预检只用于提前记录原因、跳过注定失败的重试,不参与“升级失败后做什么”的决策:冒烟测试失败一律回滚旧文件,
/// 绝不通过重启服务去赌新版能否起来。</para>
/// </remarks>
public static class UpgradePreflight
{
    /// <summary>检查新版本所需的 .NET 运行时是否已安装</summary>
    /// <param name="dir">新版本文件所在目录</param>
    /// <param name="name">应用名称,不含扩展名</param>
    /// <param name="installed">已安装运行时探测。参数为框架名,返回版本号列表;为空时探测本机</param>
    /// <returns>缺少运行时返回缺失说明;可以运行或无法判定时返回 null</returns>
    public static String? CheckRuntime(String? dir, String name, Func<String, IList<String>>? installed = null)
    {
        if (dir.IsNullOrEmpty() || name.IsNullOrEmpty()) return null;

        var file = Path.Combine(dir, name + ".runtimeconfig.json").GetFullPath();
        if (!File.Exists(file)) return null;

        var options = ReadRuntimeOptions(file);
        if (options == null) return null;

        // 自包含发布不依赖本机运行时
        if (options.ContainsKey("includedFrameworks")) return null;

        var required = new List<String[]>();
        CollectFrameworks(options["framework"], required);
        if (options["frameworks"] is IList<Object> fws)
        {
            foreach (var item in fws) CollectFrameworks(item, required);
        }
        if (required.Count == 0) return null;

        var rollForward = GetRollForward(options);

        installed ??= GetInstalledVersions;

        var missing = new List<String>();
        foreach (var item in required)
        {
            var framework = item[0];
            var version = item[1];

            var versions = installed(framework);

            // 探测不到任何已安装版本时无法判定,交给冒烟测试
            if (versions == null || versions.Count == 0) continue;

            var satisfied = false;
            foreach (var ver in versions)
            {
                if (IsSatisfied(ver, version, rollForward))
                {
                    satisfied = true;
                    break;
                }
            }
            if (satisfied) continue;

            missing.Add($"{framework} {version} (installed: {versions.Join(",")})");
        }

        return missing.Count == 0 ? null : $"Requires {missing.Join("; ")}";
    }

    #region 辅助
    private static IDictionary<String, Object?>? ReadRuntimeOptions(String file)
    {
        try
        {
            var dic = JsonParser.Decode(File.ReadAllText(file));

            return dic != null && dic["runtimeOptions"] is IDictionary<String, Object?> options ? options : null;
        }
        catch
        {
            // 解析不了就不再判定,交给冒烟测试
            return null;
        }
    }

    private static void CollectFrameworks(Object? value, List<String[]> list)
    {
        if (value is not IDictionary<String, Object?> dic) return;

        var name = dic["name"] as String;
        var version = dic["version"] as String;
        if (!name.IsNullOrEmpty() && !version.IsNullOrEmpty()) list.Add([name, version]);
    }

    private static Boolean IsSatisfied(String installed, String required, String? rollForward)
    {
        if (!TryParse(installed, out var ver) || !TryParse(required, out var target)) return installed.EqualIgnoreCase(required);

        // 预览版运行时:默认不作为候选;即便放开,也不能用同版本或更低版本的预览版顶替正式版
        if (HasPrerelease(installed) && !HasPrerelease(required) && (!PrereleaseEnabled || ver <= target)) return false;

        // 策略可写成逗号分隔的多个值,命中任意一个即可
        foreach (var item in (rollForward + "").Split(',', ';'))
        {
            if (IsSatisfied(ver, target, item.Trim())) return true;
        }

        return false;
    }

    /// <summary>按单个前滚策略判断已安装版本是否满足要求</summary>
    /// <param name="installed">已安装版本</param>
    /// <param name="required">要求的版本</param>
    /// <param name="rollForward">前滚策略,空表示默认 Minor</param>
    /// <returns>是否满足</returns>
    /// <remarks>
    /// 语义与 .NET 主机一致:Disable 要求精确版本;Major/LatestMajor 允许跨主版本;
    /// 其余策略(默认 Minor、LatestPatch、LatestMinor)只允许同一主版本内向前,且补丁号不得低于要求。
    /// </remarks>
    private static Boolean IsSatisfied(Version installed, Version required, String? rollForward)
    {
        if (rollForward.EqualIgnoreCase("Disable")) return installed == required;

        if (rollForward.EqualIgnoreCase("Major", "LatestMajor")) return installed >= required;

        return installed.Major == required.Major && installed >= required;
    }

    /// <summary>是否为预览版版本号</summary>
    /// <param name="version">版本号</param>
    /// <returns>预览版返回 true</returns>
    private static Boolean HasPrerelease(String? version) => !version.IsNullOrEmpty() && version.IndexOf('-') > 0;

    /// <summary>是否允许预览版参与前滚</summary>
    private static Boolean PrereleaseEnabled => Environment.GetEnvironmentVariable("DOTNET_ROLL_FORWARD_TO_PRERELEASE").EqualIgnoreCase("1", "true");

    /// <summary>读取前滚策略。环境变量优先于 runtimeconfig,与 .NET 主机一致</summary>
    /// <param name="options">runtimeOptions 节点</param>
    /// <returns>前滚策略;未指定时返回 null</returns>
    private static String? GetRollForward(IDictionary<String, Object?> options)
    {
        var value = Environment.GetEnvironmentVariable("DOTNET_ROLL_FORWARD");
        if (!value.IsNullOrEmpty()) return value;

        value = options["rollForward"] as String;
        if (!value.IsNullOrEmpty()) return value;

        return options["configProperties"] is IDictionary<String, Object?> props
            ? props["System.RuntimeFramework.RollForward"] as String
            : null;
    }

    private static Boolean TryParse(String? value, out Version version)
    {
        version = null!;
        if (value.IsNullOrEmpty()) return false;

        // 预览版形如 9.0.0-preview.7.24405.7,主次版本号之后的部分不参与比较
        var p = value.IndexOfAny(['-', '+']);
        if (p > 0) value = value[..p];

        return Version.TryParse(value, out version!);
    }

    /// <summary>枚举本机已安装的指定框架版本</summary>
    /// <param name="framework">框架名,如 Microsoft.NETCore.App</param>
    /// <returns>版本号列表</returns>
    private static IList<String> GetInstalledVersions(String framework)
    {
        var list = new List<String>();

        foreach (var root in GetDotNetRoots())
        {
            try
            {
                var dir = Path.Combine(root, "shared", framework);
                if (!Directory.Exists(dir)) continue;

                foreach (var item in Directory.GetDirectories(dir))
                {
                    var name = Path.GetFileName(item);
                    if (!name.IsNullOrEmpty() && !list.Contains(name)) list.Add(name);
                }
            }
            catch
            {
                // 目录不可访问时跳过
            }
        }

        return list;
    }

    /// <summary>枚举可能的 .NET 安装根目录</summary>
    /// <returns>安装根目录列表</returns>
    private static IList<String> GetDotNetRoots()
    {
        var list = new List<String>();

        var root = Environment.GetEnvironmentVariable("DOTNET_ROOT");
        if (!root.IsNullOrEmpty()) list.Add(root);

        // 当前进程所加载运行时的安装根目录:<root>/shared/Microsoft.NETCore.App/<version>
        var core = typeof(Object).Assembly.Location;
        if (!core.IsNullOrEmpty())
        {
            var dir = Path.GetDirectoryName(core);
            for (var i = 0; i < 3 && !dir.IsNullOrEmpty(); i++)
            {
                dir = Path.GetDirectoryName(dir);
            }
            if (!dir.IsNullOrEmpty()) list.Add(dir);
        }

        if (Runtime.Windows)
        {
            var dir = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
            if (!dir.IsNullOrEmpty()) list.Add(Path.Combine(dir, "dotnet"));
        }
        else if (Runtime.OSX)
        {
            list.Add("/usr/local/share/dotnet");
            list.Add("/opt/homebrew/share/dotnet");
        }
        else
        {
            list.Add("/usr/share/dotnet");
            list.Add("/usr/lib/dotnet");
            list.Add("/usr/local/share/dotnet");
        }

        var roots = new List<String>();
        foreach (var item in list)
        {
            if (!item.IsNullOrEmpty() && !roots.Contains(item)) roots.Add(item);
        }

        return roots;
    }
    #endregion
}