节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
10.88 KiB
Stardust
using System.Linq;
using System.Runtime.Intrinsics.Arm;
using Microsoft.AspNetCore.Mvc;
using NewLife;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions;
using NewLife.Serialization;
using Stardust.Data.Deployment;
using Stardust.Data.Nodes;
using Stardust.Models;
using Stardust.Server.Services;
using Attachment = Stardust.Data.Deployment.Attachment;

namespace Stardust.Server.Controllers;

/// <summary>发布中心服务</summary>
[ApiFilter]
[Route("[controller]/[action]")]
public class DeployController(NodeService nodeService, DeployService deployService, IServiceProvider serviceProvider, DownloadTokenService downloadTokenService) : BaseController(nodeService, null, serviceProvider)
{
    private readonly Version _newDeploy = new(3, 7);

    /// <summary>获取分配到本节点的应用服务信息</summary>
    /// <returns></returns>
    public DeployInfo[] GetAll(Int32 deployId, String deployName, String appName)
    {
        var node = Context.Device as Node;
        var list = AppDeployNode.FindAllByNodeId(node.ID);

        var rs = new List<DeployInfo>();
        foreach (var deployNode in list)
        {
            // 不返回未启用的发布集,如果需要在客户端删除,则通过指令下发来实现
            if (!deployNode.Enable) continue;

            // 过滤需要的应用部署
            if (deployId > 0 && deployNode.Id != deployId) continue;

            var app = deployNode.Deploy;
            if (app == null || !app.Enable) continue;

            if (!deployName.IsNullOrEmpty())
            {
                if (!deployNode.DeployName.IsNullOrEmpty() && deployNode.DeployName != deployName ||
                    deployNode.DeployName.IsNullOrEmpty() && app.Name != deployName) continue;
            }
            if (!appName.IsNullOrEmpty() && app.AppName != appName) continue;

            // 修正旧的用户名
            deployNode.FixOldUserName();

            var inf = deployService.BuildDeployInfo(deployNode, node);
            if (inf == null) continue;

            // 如果客户版本较低,Mode需要转为旧版本服务模式
            if (Version.TryParse(node.Version, out var ver) && ver < _newDeploy)
            {
                inf.Service.Mode = (DeployMode)DeployModesExtensions.Convert(inf.Service.Mode);
            }

            // 安全V-11:下发的部署包下载URL附带短时令牌
            if (!inf.Url.IsNullOrEmpty())
                inf.Url = downloadTokenService.AppendToken(inf.Url, StarServerSetting.Current.AttachmentTokenExpire);

            rs.Add(inf);
            WriteHistory(app.Id, nameof(GetAll), true, inf.ToJson());
        }

        return rs.ToArray();
    }

    /// <summary>上传本节点的所有应用服务信息</summary>
    /// <param name="services"></param>
    /// <returns></returns>
    [HttpPost]
    public Int32 Upload([FromBody] ServiceInfo[] services)
    {
        if (services == null || services.Length == 0) return 0;

        // 本节点所有发布
        var node = Context.Device as Node;
        var list = AppDeployNode.FindAllByNodeId(node.ID);

        var rs = 0;
        foreach (var svc in services)
        {
            if (svc.Name.IsNullOrEmpty()) continue;

            AppDeploy app = null;

            // 发布节点可能有自定义名字
            var dn = list.FirstOrDefault(e => e.DeployName.EqualIgnoreCase(svc.Name));
            app = dn?.Deploy;

            app ??= AppDeploy.FindByName(svc.Name);
            app ??= new AppDeploy { Name = svc.Name/*, Enable = svc.Enable*/ };

            // 仅新应用或停用应用(新增后未使用)更新应用信息
            if (app.Id == 0 || !app.Enable)
            {
                app.FileName = svc.FileName;
                app.Arguments = svc.Arguments;
                app.WorkingDirectory = svc.WorkingDirectory;
                app.Environments = svc.Environments;
                //app.Mode = svc.Mode;
                app.AutoStop = svc.AutoStop;
                app.ReloadOnChange = svc.ReloadOnChange;
                app.MaxMemory = svc.MaxMemory;
                app.Priority = svc.Priority;
                app.HealthCheck = svc.HealthCheck;

                // 客户端使用旧版本时,需要转换为新版本
                var mode = svc.Mode;
                if (mode.IsNewVersion())
                    app.Mode = mode;
                else
                {
                    var mode2 = (ServiceModes)mode;
                    app.Mode = DeployModesExtensions.Convert(mode2);
                    if (mode2 == ServiceModes.Multiple) app.AllowMultiple = true;
                }

                // 新增时才记录应用部署的用户名,避免Windows/Linux混合部署时整个应用记住了Linux的用户名
                if (app.Id == 0)
                {
                    if (app.UserName.IsNullOrEmpty()) app.UserName = svc.UserName;
                }
            }

            // 先保存,可能有插入,需要取得应用发布Id
            var rs2 = app.Save();

            if (rs2 > 0) WriteHistory(app.Id, nameof(Upload), true, svc.ToJson());

            rs += rs2;

            dn ??= list.FirstOrDefault(e => e.DeployId == app.Id);
            if (dn == null)
                dn = new AppDeployNode { DeployId = app.Id, NodeId = node.ID, Enable = svc.Enable };
            else
                list.Remove(dn);

            if (svc.Enable && app.Enable) dn.Enable = true;
            dn.LastUpload = DateTime.Now;

            dn.Save();
        }

        // 没有匹配的应用,本次禁用
        foreach (var item in list)
        {
            item.Enable = false;
            item.LastUpload = DateTime.Now;
            item.Update();
        }

        return rs;
    }

    /// <summary>应用心跳。上报应用信息</summary>
    /// <param name="inf"></param>
    /// <returns></returns>
    [HttpPost]
    public Int32 Ping([FromBody] AppInfo inf) => deployService.Ping(Context.Device as Node, inf, UserHost);

    /// <summary>获取分配到本节点的应用发布任务</summary>
    public BuildTask GetBuildTask(Int32 deployId, String deployName, String appName)
    {
        return null;
    }

    /// <summary>更新编译任务</summary>
    /// <param name="result"></param>
    [HttpPost]
    public Int32 UpdateBuildTask(BuildResult result)
    {
        var ver = AppDeployVersion.FindById(result.Id)
            ?? throw new ArgumentNullException(nameof(result));

        if (!result.CommitId.IsNullOrEmpty()) ver.CommitId = result.CommitId;
        if (!result.CommitLog.IsNullOrEmpty()) ver.CommitLog = result.CommitLog;
        if (result.CommitTime.Year > 2000) ver.CommitTime = result.CommitTime;
        if (!result.Progress.IsNullOrEmpty()) ver.Progress = result.Progress;

        return ver.Update();
    }

    /// <summary>上传编译产物包。编译节点编译完成后,上传zip包并自动创建应用版本</summary>
    /// <param name="deployName">应用部署集名称</param>
    /// <param name="version">版本号</param>
    /// <param name="commitId">提交标识</param>
    /// <param name="commitLog">提交记录</param>
    /// <param name="commitTime">提交时间</param>
    /// <param name="file">zip文件</param>
    /// <returns></returns>
    [HttpPost]
    // 此值的意义是确保 multipart 解析不成为瓶颈(设为1GB),真正的上传包大小上限由入口处的 Startup 中间件控制:
    // 中间件根据 StarServerSetting.MaxUploadSize 动态设置 Kestrel 的 IHttpMaxRequestBodySizeFeature,
    // 在请求到达 Controller 之前即可拒绝超限请求,此特性仅作为 multipart 解析的上限兜底
    [RequestFormLimits(MultipartBodyLengthLimit = 1 * 1024 * 1024 * 1024)]
    public async Task<Object> UploadBuildFile(String deployName, String version, String? commitId, String? commitLog, String? commitTime, [FromForm] IFormFile file)
    {
        if (deployName.IsNullOrEmpty()) throw new ApiException(400, L.T("The application deployment set name cannot be empty."));
        if (version.IsNullOrEmpty()) throw new ApiException(400, L.T("Version cannot be empty."));
        if (file == null || file.Length == 0) throw new ApiException(400, L.T("The uploaded file cannot be empty."));

        var node = Context.Device as Node;

        // 查找应用部署集
        var app = AppDeploy.FindByName(deployName);
        if (app == null) throw new ApiException(404, L.T("Application deployment set [{0}] does not exist.", deployName));

        // 安全V-4:仅允许该发布集的编译/部署关联节点上传构建产物,防止任意已注册节点覆盖升级包(供应链投毒)
        var nodeId = node?.ID ?? 0;
        if (nodeId <= 0) throw new ApiException(ApiCode.Forbidden, L.T("Unable to identify the uploading node."));
        var buildNodes = AppBuildNode.FindAll(AppBuildNode._.DeployId == app.Id);
        var deployNodes = AppDeployNode.FindAllByDeployId(app.Id);
        if (!buildNodes.Any(e => e.Enable && e.NodeId == nodeId) && !deployNodes.Any(e => e.Enable && e.NodeId == nodeId))
            throw new ApiException(ApiCode.Forbidden, L.T("Node [{0}] is not a build/deploy node of deployment set [{1}]; uploading build artifacts is forbidden. Configure this node in the deployment set.", node?.Name, app.Name));

        var set = StarServerSetting.Current;

        // 创建或更新版本
        var ver = AppDeployVersion.FindByDeployIdAndVersion(app.Id, version);
        if (ver == null)
        {
            ver = new AppDeployVersion
            {
                DeployId = app.Id,
                Version = version,
                Enable = true,
            };
        }

        ver.TraceId = DefaultSpan.Current?.TraceId;

        // 设置Git提交信息
        if (!commitId.IsNullOrEmpty()) ver.CommitId = commitId;
        if (!commitLog.IsNullOrEmpty()) ver.CommitLog = commitLog;
        if (!commitTime.IsNullOrEmpty()) ver.CommitTime = commitTime.ToDateTime();

        // 创建附件并保存文件
        var att = new Attachment
        {
            Category = "AppDeploy",
            Key = app.Name,
            Title = app.Name,
            FileName = file.FileName,
            ContentType = file.ContentType,
            UploadTime = DateTime.Now,
        };

        using var stream = file.OpenReadStream();
        await att.SaveFile(stream, set.UploadPath).ConfigureAwait(false);

        ver.Url = $"/cube/file?id={att.Id}{att.Extension}";
        ver.Hash = att.Hash;
        ver.Size = att.Size;

        ver.Save();
        app.Fix();

        WriteHistory(app.Id, nameof(UploadBuildFile), true, L.T("Version={0} Size={1:n0} Node={2}", version, att.Size, node?.Name));

        return new { ver.Id, ver.Version, ver.Url, ver.Size, ver.Hash };
    }

    #region 辅助
    private void WriteHistory(Int32 appId, String action, Boolean success, String remark) => deployService.WriteHistory(appId, (Context.Device as Node)?.ID ?? 0, action, success, remark, UserHost);
    #endregion
}