节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
18.57 KiB
Stardust
using System.Collections.Concurrent;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NewLife;
using NewLife.Log;
using NewLife.Serialization;
using Stardust.Data.Deployment;
using Stardust.Models;

namespace Stardust.Web.Services;

/// <summary>流水线编排服务。管理流水线运行的完整生命周期:创建运行记录 → 触发编译 → 查版本 → 部署。
/// 同时提供 webhook 入口,让 git push 与手动触发共用同一份 RunAsync 编排,严格复用 DeployService.Compile/Control</summary>
public class PipelineService
{
    private readonly DeployService _deployService;
    private readonly ITracer _tracer;

    /// <summary>防抖时间窗口。同一 commit 在窗口内只允许触发一次</summary>
    private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(5);

    /// <summary>运行中的 CancellationTokenSource 字典(按 runId 索引)。用于支持流水线取消</summary>
    private readonly ConcurrentDictionary<Int64, CancellationTokenSource> _runningCts = new();

    public PipelineService(DeployService deployService, ITracer tracer)
    {
        _deployService = deployService;
        _tracer = tracer;
    }

    #region 手动触发
    /// <summary>手动触发流水线运行。创建运行记录,异步执行编译→上传→部署编排</summary>
    /// <param name="pipelineId">流水线编号</param>
    /// <param name="userHost">触发者 IP</param>
    /// <returns>新创建的运行记录</returns>
    public async Task<AppPipelineRun> Trigger(Int32 pipelineId, String userHost)
    {
        var pipeline = AppPipeline.FindById(pipelineId);
        if (pipeline == null) throw new ArgumentNullException(nameof(pipelineId), L.T("Pipeline does not exist."));
        if (!pipeline.Enable) throw new InvalidOperationException(L.T("Pipeline is not enabled."));

        var app = AppDeploy.FindById(pipeline.DeployId);
        if (app == null) throw new InvalidOperationException(L.T("Application deployment set [{0}] does not exist.", pipeline.DeployId));

        var buildNode = AppBuildNode.FindAllByNodeId(pipeline.BuildNodeId).FirstOrDefault(e => e.DeployId == pipeline.DeployId);
        if (buildNode == null) throw new InvalidOperationException(L.T("Build node [{0}] does not exist or is not associated with deployment set [{1}].", pipeline.BuildNodeId, pipeline.DeployId));
        if (!buildNode.Enable) throw new InvalidOperationException(L.T("Build node [{0}] is not enabled.", buildNode));

        var run = new AppPipelineRun
        {
            PipelineId = pipeline.Id,
            Status = PipelineStatus.Pending,
            TriggerSource = "manual",
            Branch = pipeline.Branch,
            BuildNodeId = pipeline.BuildNodeId,
        };
        run.Insert();

        var cts = new CancellationTokenSource();
        _runningCts[run.Id] = cts;
        _ = Task.Run(() => RunAsync(run, pipeline, app, buildNode, userHost, cts.Token));

        return run;
    }

    /// <summary>重新处理流水线运行(重试失败或被中断的运行)。基于原运行创建新记录并执行</summary>
    /// <param name="runId">原运行记录编号</param>
    /// <param name="userHost">触发者 IP</param>
    /// <returns>新创建的运行记录</returns>
    public async Task<AppPipelineRun> Reprocess(Int32 runId, String userHost)
    {
        var src = AppPipelineRun.FindById(runId);
        if (src == null) throw new ArgumentNullException(nameof(runId), L.T("Run record does not exist."));

        var pipeline = AppPipeline.FindById(src.PipelineId);
        if (pipeline == null) throw new InvalidOperationException(L.T("The pipeline [{0}] of the original run does not exist.", src.PipelineId));
        if (!pipeline.Enable) throw new InvalidOperationException(L.T("Pipeline [{0}] is not enabled.", pipeline.Name));

        var app = AppDeploy.FindById(pipeline.DeployId);
        if (app == null) throw new InvalidOperationException(L.T("Application deployment set [{0}] does not exist.", pipeline.DeployId));

        var buildNode = AppBuildNode.FindAllByNodeId(pipeline.BuildNodeId).FirstOrDefault(e => e.DeployId == pipeline.DeployId);
        if (buildNode == null) throw new InvalidOperationException(L.T("Build node [{0}] does not exist or is not associated with deployment set [{1}].", pipeline.BuildNodeId, pipeline.DeployId));
        if (!buildNode.Enable) throw new InvalidOperationException(L.T("Build node [{0}] is not enabled.", buildNode));

        var run = new AppPipelineRun
        {
            PipelineId = src.PipelineId,
            Status = PipelineStatus.Pending,
            TriggerSource = "reprocess",
            CommitId = src.CommitId,
            CommitMessage = src.CommitMessage,
            CommitAuthor = src.CommitAuthor,
            CommitTime = src.CommitTime == default ? DateTime.Now : src.CommitTime,
            Branch = src.Branch,
            BuildNodeId = src.BuildNodeId,
        };
        run.Insert();

        var cts = new CancellationTokenSource();
        _runningCts[run.Id] = cts;
        _ = Task.Run(() => RunAsync(run, pipeline, app, buildNode, userHost, cts.Token));

        return run;
    }
    #endregion

    #region Webhook 入口
    /// <summary>处理 git push webhook 入口。解析 payload、分支匹配、防抖后创建 run 并异步执行</summary>
    /// <param name="token">webhook 鉴权 token(AppPipeline.Token)</param>
    /// <param name="body">原始请求体</param>
    /// <param name="signature">GitHub 风格 X-Hub-Signature-256,可选</param>
    /// <returns>处理结果对象,形如 {result:"accepted", runId, status}</returns>
    public async Task<Object> HandleWebhookAsync(String token, String body, String signature)
    {
        if (token.IsNullOrEmpty()) return new { result = "error", reason = "missing token" };

        var pipeline = AppPipeline.FindByToken(token);
        if (pipeline == null) return new { result = "error", reason = "invalid token" };
        if (!pipeline.Enable) return new { result = "ignored", reason = "pipeline disabled" };

        // 签名校验(可选,配置了 Secret 才校验)
        if (!pipeline.Secret.IsNullOrEmpty())
        {
            if (signature.IsNullOrEmpty()) return new { result = "error", reason = "missing signature" };
            if (!VerifySignature(pipeline.Secret, body, signature)) return new { result = "error", reason = "invalid signature" };
        }

        // 解析 payload
        var (branch, commitId, commitMessage, commitAuthor, commitTime) = ParsePayload(body);
        if (branch.IsNullOrEmpty()) return new { result = "ignored", reason = "no branch" };

        // 分支匹配
        if (!MatchBranch(pipeline.Branch, branch)) return new { result = "ignored", reason = "branch mismatch" };

        // 防抖:同 pipeline + 同 commit 在 5 分钟内有进行中或已成功的 run 则跳过
        if (!commitId.IsNullOrEmpty() && IsDuplicate(pipeline.Id, commitId, DedupWindow))
            return new { result = "skipped", reason = "duplicate" };

        // 关联实体校验,避免创建无法执行的 run
        var buildNode = AppBuildNode.FindAllByNodeId(pipeline.BuildNodeId).FirstOrDefault(e => e.DeployId == pipeline.DeployId);
        if (buildNode == null || !buildNode.Enable) return new { result = "error", reason = "build node unavailable" };

        var app = AppDeploy.FindById(pipeline.DeployId);
        if (app == null) return new { result = "error", reason = "deploy not found" };

        // 创建 run(Pending)
        var run = new AppPipelineRun
        {
            PipelineId = pipeline.Id,
            Status = PipelineStatus.Pending,
            TriggerSource = "webhook",
            CommitId = commitId,
            CommitMessage = commitMessage,
            CommitAuthor = commitAuthor,
            CommitTime = commitTime == default ? DateTime.Now : commitTime,
            Branch = branch,
            BuildNodeId = pipeline.BuildNodeId,
        };
        run.Insert();

        // 异步执行,不阻塞 webhook 响应
        var cts = new CancellationTokenSource();
        _runningCts[run.Id] = cts;
        _ = Task.Run(() => RunAsync(run, pipeline, app, buildNode, "webhook", cts.Token));

        return new { result = "accepted", runId = run.Id, status = run.Status.ToString() };
    }
    #endregion

    #region 取消
    /// <summary>取消运行中的流水线。校验状态后置为 Cancelled,并触发 CTS 中断后续步骤</summary>
    /// <param name="runId">运行记录编号</param>
    /// <param name="userHost">取消者 IP</param>
    /// <returns>是否取消成功(run 不存在或已终态返回 false)</returns>
    public Boolean Cancel(Int32 runId, String userHost)
    {
        var run = AppPipelineRun.FindById(runId);
        if (run == null) throw new ArgumentNullException(nameof(runId), L.T("Run record does not exist."));

        // 仅允许取消未到终态的 run
        if (run.Status is PipelineStatus.Success or PipelineStatus.Failed or PipelineStatus.Cancelled)
            return false;

        // 触发 CTS 中断 RunAsync 中的后续步骤
        if (_runningCts.TryRemove(runId, out var cts))
        {
            try { cts.Cancel(); } catch { /* 忽略取消异常 */ }
            cts.Dispose();
        }

        // 标记为已取消
        run.Status = PipelineStatus.Cancelled;
        run.Remark = L.T("Cancelled by user (IP: {0})", userHost);
        if (run.BuildStartedTime != default && run.BuildFinishedTime == default) run.BuildFinishedTime = DateTime.Now;
        if (run.DeployStartedTime != default && run.DeployFinishedTime == default) run.DeployFinishedTime = DateTime.Now;
        run.Update();

        // 把还在 Running 的 step 标记为 Skipped
        var steps = AppPipelineStep.FindAll(AppPipelineStep._.RunId == runId & AppPipelineStep._.Status == "Running");
        foreach (var s in steps)
        {
            s.Status = "Skipped";
            s.Message = L.T("Run cancelled.");
            s.FinishedTime = DateTime.Now;
            s.Update();
        }

        return true;
    }
    #endregion

    #region 主流程
    /// <summary>异步执行流水线编排:仅负责「下发编译命令并记录 CommandId」,绝不等待编译结果、绝不在此标成功。
    /// 编译真正完成后由 StarServer 的 CommandReply 事件驱动续跑(取版本 / 使用版本 / 自动部署),webhook 与手动两条路径共用此方法,严格复用 DeployService.Compile。
    /// 每个阶段写入 AppPipelineStep 记录,方便从 run 详情页查看各阶段状态。</summary>
    private async Task RunAsync(AppPipelineRun run, AppPipeline pipeline, AppDeploy app, AppBuildNode buildNode, String userHost, CancellationToken cancellationToken)
    {
        using var span = _tracer?.NewSpan($"Pipeline-{run.Id}", run);
        run.TraceId = span?.TraceId;
        run.Update();

        try
        {
            // ★ 编译前检查取消
            if (cancellationToken.IsCancellationRequested)
            {
                run.Status = PipelineStatus.Cancelled;
                run.Update();
                return;
            }

            // ---------- 编译阶段:仅下发编译命令并记 CommandId,不等待、不标成功 ----------
            run.Status = PipelineStatus.Building;
            run.BuildStartedTime = DateTime.Now;
            run.Update();

            var buildStep = new AppPipelineStep
            {
                RunId = run.Id,
                StepType = "Build",
                StepIndex = 0,
                NodeId = buildNode.NodeId,
                Status = "Running",
                StartedTime = run.BuildStartedTime,
                CreateTime = DateTime.Now,
            };
            buildStep.Insert();

            try
            {
                // 复用手动「编译上传」;writeHistory:false(真实历史由节点 PostEvents 与 CommandReply 事件写入)
                // fire-and-forget 下发:返回 NodeCommand.Id;编译真正完成由 StarServer 的 CommandReply 事件驱动续跑
                var cmdId = await _deployService.Compile(app, buildNode, "Build-Upload", userHost, cancellationToken, writeHistory: false);
                buildStep.CommandId = cmdId;
                buildStep.Update();

                // 命令未真正发出(如 StarFactory 未就绪),reply?.Id 为 0:再不会有回包事件,必须立即失败,否则永远卡 Building
                if (cmdId == 0)
                {
                    buildStep.Status = "Failed";
                    buildStep.FinishedTime = DateTime.Now;
                    buildStep.Message = L.T("Build command was not sent (node may not be ready).");
                    buildStep.Update();
                    run.BuildFinishedTime = DateTime.Now;
                    run.Status = PipelineStatus.Failed;
                    run.Remark = L.T("Build command was not sent (node may not be ready).");
                    run.Update();
                    return;
                }
            }
            catch (Exception ex)
            {
                // 命令下发即失败(如节点不存在):立即标记失败
                buildStep.Status = "Failed";
                buildStep.FinishedTime = DateTime.Now;
                buildStep.Message = ex.Message;
                buildStep.Update();
                run.BuildFinishedTime = DateTime.Now;
                run.Status = PipelineStatus.Failed;
                run.Remark = ex.Message;
                run.Update();
                return;
            }

            // 编译已下发,等待节点真正执行完后的 CommandReply 事件来更新步骤/run(不轮询、不等待)
            return;
        }
        catch (Exception ex)
        {
            span?.SetError(ex, null);
            run.Status = PipelineStatus.Failed;
            run.Remark = ex.Message;
            run.Update();
        }
        finally
        {
            // 清理 CTS 字典(无论成功/失败/取消,run 已终态)
            if (_runningCts.TryRemove(run.Id, out var cts))
            {
                try { cts.Dispose(); } catch { /* 忽略 */ }
            }
        }
    }
    #endregion

    #region Webhook 辅助
    /// <summary>分支匹配。支持精确匹配与末尾通配符(release/*)</summary>
    private Boolean MatchBranch(String pattern, String actual)
    {
        if (pattern.IsNullOrEmpty()) return true;
        if (actual.IsNullOrEmpty()) return false;

        // 末尾 * 视作通配符
        if (pattern.EndsWith("*"))
        {
            var prefix = pattern[..^1];
            return actual.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
        }

        return pattern.Equals(actual, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>HMAC-SHA256 校验(GitHub 风格 X-Hub-Signature-256)</summary>
    private Boolean VerifySignature(String secret, String body, String signature)
    {
        if (secret.IsNullOrEmpty() || body == null || signature.IsNullOrEmpty()) return false;

        // 形如 "sha256=xxxxx"
        var sig = signature.StartsWithIgnoreCase("sha256=") ? signature[7..] : signature;

        try
        {
            using var hmac = new HMACSHA256(secret.GetBytes());
            var hash = hmac.ComputeHash(body.GetBytes());
            var expected = hash.ToBase64();
            return expected.Equals(sig, StringComparison.OrdinalIgnoreCase);
        }
        catch
        {
            return false;
        }
    }

    /// <summary>解析通用 webhook payload(尽力兼容 GitHub/GitLab)</summary>
    private (String branch, String commitId, String commitMessage, String commitAuthor, DateTime commitTime) ParsePayload(String body)
    {
        if (body.IsNullOrEmpty()) return default;

        try
        {
            var json = body.ToJsonEntity<WebhookPayload>();
            if (json == null) return default;

            // GitHub: ref=refs/heads/main, head_commit.id
            var branch = json.Ref;
            if (!branch.IsNullOrEmpty() && branch.StartsWithIgnoreCase("refs/heads/")) branch = branch["refs/heads/".Length..];

            // GitLab: object_attributes.source_branch / checkout_sha
            if (branch.IsNullOrEmpty() && json.ObjectAttributes != null) branch = json.ObjectAttributes.SourceBranch;
            var commitId = json.After ?? json.CheckoutSha ?? json.HeadCommit?.Id;
            if (commitId.IsNullOrEmpty() && json.ObjectAttributes != null) commitId = json.ObjectAttributes.CheckoutSha;

            var commitMessage = json.HeadCommit?.Message ?? json.ObjectAttributes?.Message;
            var commitAuthor = json.HeadCommit?.Author?.Name ?? json.UserName ?? json.ObjectAttributes?.AuthorName;
            // 时间戳:head_commit.timestamp → commits[0].timestamp → object_attributes.timestamp
            var commitTime = json.HeadCommit?.Timestamp
                ?? json.Commits?.FirstOrDefault()?.Timestamp
                ?? json.ObjectAttributes?.Timestamp
                ?? default;

            return (branch, commitId, commitMessage, commitAuthor, commitTime);
        }
        catch
        {
            return default;
        }
    }

    /// <summary>同 commit 在窗口内已有进行中或已成功的 run 视为重复</summary>
    private Boolean IsDuplicate(Int32 pipelineId, String commitId, TimeSpan window)
    {
        if (commitId.IsNullOrEmpty()) return false;

        var since = DateTime.Now - window;
        var exists = AppPipelineRun.FindAll(AppPipelineRun._.PipelineId == pipelineId & AppPipelineRun._.CommitId == commitId & AppPipelineRun._.CreateTime >= since);
        return exists.Any(e => e.Status is PipelineStatus.Pending or PipelineStatus.Building or PipelineStatus.UploadSucceeded or PipelineStatus.Deploying or PipelineStatus.Success);
    }
    #endregion
}

/// <summary>通用 webhook payload(GitHub/GitLab/Gitea 兼容)</summary>
internal class WebhookPayload
{
    public String Ref { get; set; }
    public String After { get; set; }
    public String CheckoutSha { get; set; }
    public String UserName { get; set; }
    public WebhookCommit HeadCommit { get; set; }
    public WebhookObject ObjectAttributes { get; set; }
    public WebhookCommit[] Commits { get; set; }
}

internal class WebhookCommit
{
    public String Id { get; set; }
    public String Message { get; set; }
    public DateTime Timestamp { get; set; }
    public WebhookAuthor Author { get; set; }
}

internal class WebhookAuthor
{
    public String Name { get; set; }
}

internal class WebhookObject
{
    public String SourceBranch { get; set; }
    public String CheckoutSha { get; set; }
    public String Message { get; set; }
    public DateTime Timestamp { get; set; }
    public String AuthorName { get; set; }
}